Inference for Interpretable Text-Diffusion Models

Flat illustration of noisy token blocks becoming solid blue and black squares through connected concept pathways.

Authors: Muawiz Chaudhary, Research EngineerAndreas Madsen, Founding Research Scientist
Published: October 29, 2026

We present the Causal Diffusion Engine (CDE), a serving engine for interpretable diffusion language models. CDE powers Clarity, our platform for serving Steerling, our 8B-parameter block-causal diffusion LLM that traces any group of generated tokens back to human-understandable concepts, and lets users steer generation through those concepts. Existing production serving engines such as vLLM and SGLang do not treat interpretability as a first-class citizen. CDE delivers low-latency diffusion language model serving while enabling interpretability primitives like concept attributions and steering alongside every generated token. We describe the design decisions that make this possible, including block-causal paged attention with a KV cache, specialized torch.compile graphs, memory-efficient concept heads, and request-time concept steering without recompilation.

Introduction

At Guide Labs, we build models whose representations are transparent, so that humans can audit and understand them. Our recent release, Steerling-8B, is a diffusion language model that generates text in 64-token blocks, filling in masked tokens over several steps rather than one token at a time. Every token’s prediction passes through a layer of human-understandable concepts, so the model can report which concepts drove each output chunk (concept attribution) and users can shift the output by adjusting those concepts (concept steering).

Clarity, our platform, puts these capabilities in front of users. To serve Steerling-8B in Clarity, we sought a serving engine that provides production-grade latency and throughput while treating the interpretability properties of Steerling as first-class citizens.

Below, Steerling writes one 64-token block at a time: each block starts as a row of masks and comes into focus over many steps, its words landing out of order rather than left to right. As each word lands, the concepts behind it build up in the panel beside the text, and once a block is complete, its words and their concepts are fixed for good.

Loading…

Goals for an Interpretable Serving Engine

We set out to build a serving engine that is:

We found that no existing engines met these goals. vLLM and SGLang assume autoregressive, text-only generation. Steering extensions such as EasySteer inherit those assumptions. Diffusion engines such as Diffulex lack block-causal attention support, compilation, dynamic batching, and any notion of interpretable outputs. So we built the Causal Diffusion Engine (CDE).

Background

Three properties of Steerling shape every design decision in CDE: it decodes block by block, it routes predictions through concepts, and it accepts concept-level interventions.

D = 2 tokens per diffusion-block · B = 2 tokens per block-tile
3 diffusion-blocks · Each small square = one query–key token pair

← query tokens
key/value tokens →
BlockMask Tiles Fully included · skip masking ×Fully skipped · skip attention

Purple tiles are fully included. Empty tiles are fully skipped.

Aligned tiles: B = D. Each block-tile covers one pair of diffusion-blocks.
Diagram showing Steerling's embedding decomposition into known concepts, discovered concepts, and residual

For the full model, see Scaling Inherently Interpretable Language Models.

System Overview

We will now discuss the high-level design of CDE.

architecture diagram

Architecture diagram of our serving engine.

Core Components

Six core components carry requests through the four stages described below, coordinating request scheduling, model execution, and the delivery of generated text with concept attributions:

Inbound Request

A request to CDE flows through four main stages:

  1. Admission: The request, containing a prompt and optional steering concepts, is admitted by the scheduler into a batch.
  2. Prefill: Completed blocks from the prompt are processed, and their keys and values are written into the paged KV cache.
  3. Block-by-Block Decode: The model unmasks the next 64-token block, one step at a time. When a block has no masked tokens left, a final pass commits it to the cache.
  4. Attribution: Concept attributions are computed for each sampled token and returned with the generated text.

Decode Step

Almost all of a request’s time is spent in this loop:

for block in blocks_to_generate:
    while block.has_masked_tokens():
        logits, concepts = model_runner.decode(batch)      # compiled: steering, transformer,
                                                           #   concept heads, LM head
        tokens = sampler.unmask_one(logits, block)         # outside the graph
        attribution.record(concepts, tokens)               # outside the graph
        steering_injector.update(block.unmasked_positions)
    model_runner.commit(block)                             # write the block's KV to the cache

The boundary between compiled and uncompiled code is the most important line in this loop. Everything from steering injection through the transformer, concept heads, and language modeling head runs inside a single torch.compile graph. Sampling and attribution depend on which token was just chosen, so they run outside it. Every step therefore passes from GPU to CPU and back, a cost that grows with batch size (see Dynamic Batching).

Serving Concept Attribution

Serving engines are built to return tokens, and sometimes per-token hidden states, but Clarity also needs a non-token output: the concepts that produced each chunk of tokens. Serving these attributions raises two problems. It must not slow down generation, and the concept heads must fit in GPU memory alongside the model and KV cache. CDE solves the first by keeping almost all of the work inside the compiled graph, and the second by never materializing the largest concept head, which cuts its memory from 16 GB to 1 GB per sequence.

serving concept attribution

This diagram shows how concept attributions are implemented within our model, with highlights on what is included in the torch.compile region and what is not.

Keeping attribution fast. Producing concept attributions from Steerling’s forward pass involves three steps:

  1. Concept heads. Hidden states feed the concept heads. Each outputs weighted concept embeddings, concept indices, and logits.
  2. Language modeling head. The concept embeddings are summed with a residual term and passed to the language modeling head to produce token logits.
  3. Attribution. After a token is sampled, the attribution module turns the raw concept indices and logits into concept attributions.

Steps 1 and 2 run inside the torch.compile graph, so producing concept embeddings adds no separate pass. Only step 3 runs outside it, because it depends on the sampled token.

Fitting the concept heads in memory. The largest memory cost of step 1 comes from the larger of the two heads: the discovery head, with over 101K concepts. A naive multiplication against the discovery head took 16 GB for a single sequence. The discovery head is stored as two factorized matrices with a small inner dimension r. Multiplying through the r-dimensional space, instead of forming the full 101K-wide result, reduces computation and brings memory to 1 GB, a 16× reduction.

Steering

Steering is difficult to serve for three reasons. Every request in a batch can steer toward different concepts, or not at all. A request’s steering changes from one decoding step to the next. Lastly, turning steering on for a request must not trigger a recompile.

steering diagram

The steering injector adds each request’s concept embedding to the hidden states at layer 7, inside the compiled forward pass.

A request can name the concepts it wants to steer toward. The steering injector turns them into a concept embedding and adds it to the hidden states at layer 7, roughly a quarter of the way through Steerling’s 32 layers. Each request in a batch carries its own vector; for requests without steering, the vector is zero.

Each vector follows a schedule. At every step, the injector zeroes the vector at positions that are already unmasked, so steering acts only on tokens still being decided. Once a threshold number of tokens has been unmasked, the injector zeroes the whole vector, ending steering for that request.

Because the concept embedding is always part of the compiled forward pass, CDE never recompiles to turn steering on or off. The cost of this always-on addition is small: 1–2% in our measurements.

Making CDE Fast

Now that we have covered how the CDE serves concept attribution and steering, we will turn to the rest of the components that work together to deliver a fast and performant engine. First, recall the decode loop from the System Overview: the CDE samples one token per forward pass of an 8B model, so a 256-token response takes hundreds of passes. Three things decide how long that takes, and the next three sections take them in turn:

Together, these three costs set CDE’s latency and throughput. The next three sections tackle them in order, and the GSM8K comparison at the end shows where that leaves CDE against other diffusion serving engines.

Compilation Strategy

In serving, we encounter different configurations and input shapes: prefill vs. decode, varying batch sizes, and the presence or absence of steering and attribution. Our implementation often triggered recompilations or graph breaks for all these differences. We address this by making several key decisions, the most impactful being the ones we describe here:

cache diagram

Visualization of the two graph designs. We use both dynamic (left) and specialized graphs (right).

Performance Comparison

SetupTime to first block (s)Inter-block latency (s)Time to last block (s)Throughput (tok/s)
Not compiled1.862.264.6815.77
Compiled0.690.781.7244.38

Setup: batch size 1, A100, prompt lengths: 1152-1216, max generation length: 256, one token unmasked per forward pass.

Compiling with torch.compile reduces Steerling’s time-to-last-block from 4.68s to 1.72s.

Compilation also makes debugging harder: out-of-memory errors inside a compiled region report an allocation in a private memory pool rather than identifying the precise operation that caused it (see Serving Concept Attribution). We found that having the option to profile without torch.compile is invaluable.

Block-Causal Paged Attention

CDE features a custom block-causal attention mechanism, implemented as a fused FlexAttention kernel where the BlockMask size is perfectly aligned with the diffusion block size and KV cache page size, so the FlexAttention BlockMask can be perfectly sparse. These optimizations are not possible in mainstream serving engines, prompting us to build our own.

FlexAttention with Aligned Tiles

FlexAttention generates a fused attention kernel using a BlockMask, which represents the attention pattern in B×B tiles. Each tile is either fully included, fully excluded, or partially included. By setting the tile size B equal to the diffusion block size D = 64, boundaries always align, ensuring every tile either participates fully in attention or is excluded entirely. This optimization lets fully included tiles bypass masking computations, while fully excluded tiles skip attention computation altogether.

KV Cache

Without caching, every unmasking step would need to recompute attention over the entire sequence, causing compute requirements to grow quadratically with sequence length. However, since completed blocks are never resampled (Background), CDE can cache their keys and values to accelerate future sampling steps. The process works as follows:

  1. Prefill: Completed blocks from the prompt are processed and written to the cache; any prompt tokens that don’t fill a complete block remain fixed within the first block of decoding.
  2. Decode: The next block is processed using the cache; tokens within that block are unmasked one per step, in no fixed order, until the block is complete.
  3. Commit: After a block is fully unmasked, a final pass is run (without unmasking) to commit it to the cache.

Because prefill and decode operate on different attention shapes, each requires its own BlockMask and thus its own compiled graph (torch.compile).

A KV cache for block-causal attention

D = 4 tokens per diffusion-block 10 prompt tokens max prefill length = 16 tokens
Process max prefill length
Prefill pass
Process max prefill lengthProcess all 16 prefill positions. Blue p tokens are fixed prompt tokens, gray question marks are masked, green g tokens are decoded, and purple boxes contain cached KV.B0p1p2p3p4B1p5p6p7p8B2p9p10??B3????Steerlingforward passKV cache after prefillB0KVB1KV16 positions, 10 prompt tokens
Prefill BlockMask
B0B1B2B3Qquery 1 → key 1: allowedquery 1 → key 2: allowedquery 1 → key 3: allowedquery 1 → key 4: allowedquery 1 → key 5: blocked (future block)query 1 → key 6: blocked (future block)query 1 → key 7: blocked (future block)query 1 → key 8: blocked (future block)query 1 → key 9: blocked (future block)query 1 → key 10: blocked (future block)query 1 → key 11: blocked (future block)query 1 → key 12: blocked (future block)query 1 → key 13: blocked (future block)query 1 → key 14: blocked (future block)query 1 → key 15: blocked (future block)query 1 → key 16: blocked (future block)query 2 → key 1: allowedquery 2 → key 2: allowedquery 2 → key 3: allowedquery 2 → key 4: allowedquery 2 → key 5: blocked (future block)query 2 → key 6: blocked (future block)query 2 → key 7: blocked (future block)query 2 → key 8: blocked (future block)query 2 → key 9: blocked (future block)query 2 → key 10: blocked (future block)query 2 → key 11: blocked (future block)query 2 → key 12: blocked (future block)query 2 → key 13: blocked (future block)query 2 → key 14: blocked (future block)query 2 → key 15: blocked (future block)query 2 → key 16: blocked (future block)query 3 → key 1: allowedquery 3 → key 2: allowedquery 3 → key 3: allowedquery 3 → key 4: allowedquery 3 → key 5: blocked (future block)query 3 → key 6: blocked (future block)query 3 → key 7: blocked (future block)query 3 → key 8: blocked (future block)query 3 → key 9: blocked (future block)query 3 → key 10: blocked (future block)query 3 → key 11: blocked (future block)query 3 → key 12: blocked (future block)query 3 → key 13: blocked (future block)query 3 → key 14: blocked (future block)query 3 → key 15: blocked (future block)query 3 → key 16: blocked (future block)query 4 → key 1: allowedquery 4 → key 2: allowedquery 4 → key 3: allowedquery 4 → key 4: allowedquery 4 → key 5: blocked (future block)query 4 → key 6: blocked (future block)query 4 → key 7: blocked (future block)query 4 → key 8: blocked (future block)query 4 → key 9: blocked (future block)query 4 → key 10: blocked (future block)query 4 → key 11: blocked (future block)query 4 → key 12: blocked (future block)query 4 → key 13: blocked (future block)query 4 → key 14: blocked (future block)query 4 → key 15: blocked (future block)query 4 → key 16: blocked (future block)query 5 → key 1: allowedquery 5 → key 2: allowedquery 5 → key 3: allowedquery 5 → key 4: allowedquery 5 → key 5: allowedquery 5 → key 6: allowedquery 5 → key 7: allowedquery 5 → key 8: allowedquery 5 → key 9: blocked (future block)query 5 → key 10: blocked (future block)query 5 → key 11: blocked (future block)query 5 → key 12: blocked (future block)query 5 → key 13: blocked (future block)query 5 → key 14: blocked (future block)query 5 → key 15: blocked (future block)query 5 → key 16: blocked (future block)query 6 → key 1: allowedquery 6 → key 2: allowedquery 6 → key 3: allowedquery 6 → key 4: allowedquery 6 → key 5: allowedquery 6 → key 6: allowedquery 6 → key 7: allowedquery 6 → key 8: allowedquery 6 → key 9: blocked (future block)query 6 → key 10: blocked (future block)query 6 → key 11: blocked (future block)query 6 → key 12: blocked (future block)query 6 → key 13: blocked (future block)query 6 → key 14: blocked (future block)query 6 → key 15: blocked (future block)query 6 → key 16: blocked (future block)query 7 → key 1: allowedquery 7 → key 2: allowedquery 7 → key 3: allowedquery 7 → key 4: allowedquery 7 → key 5: allowedquery 7 → key 6: allowedquery 7 → key 7: allowedquery 7 → key 8: allowedquery 7 → key 9: blocked (future block)query 7 → key 10: blocked (future block)query 7 → key 11: blocked (future block)query 7 → key 12: blocked (future block)query 7 → key 13: blocked (future block)query 7 → key 14: blocked (future block)query 7 → key 15: blocked (future block)query 7 → key 16: blocked (future block)query 8 → key 1: allowedquery 8 → key 2: allowedquery 8 → key 3: allowedquery 8 → key 4: allowedquery 8 → key 5: allowedquery 8 → key 6: allowedquery 8 → key 7: allowedquery 8 → key 8: allowedquery 8 → key 9: blocked (future block)query 8 → key 10: blocked (future block)query 8 → key 11: blocked (future block)query 8 → key 12: blocked (future block)query 8 → key 13: blocked (future block)query 8 → key 14: blocked (future block)query 8 → key 15: blocked (future block)query 8 → key 16: blocked (future block)query 9 → key 1: allowedquery 9 → key 2: allowedquery 9 → key 3: allowedquery 9 → key 4: allowedquery 9 → key 5: allowedquery 9 → key 6: allowedquery 9 → key 7: allowedquery 9 → key 8: allowedquery 9 → key 9: allowedquery 9 → key 10: allowedquery 9 → key 11: allowedquery 9 → key 12: allowedquery 9 → key 13: blocked (future block)query 9 → key 14: blocked (future block)query 9 → key 15: blocked (future block)query 9 → key 16: blocked (future block)query 10 → key 1: allowedquery 10 → key 2: allowedquery 10 → key 3: allowedquery 10 → key 4: allowedquery 10 → key 5: allowedquery 10 → key 6: allowedquery 10 → key 7: allowedquery 10 → key 8: allowedquery 10 → key 9: allowedquery 10 → key 10: allowedquery 10 → key 11: allowedquery 10 → key 12: allowedquery 10 → key 13: blocked (future block)query 10 → key 14: blocked (future block)query 10 → key 15: blocked (future block)query 10 → key 16: blocked (future block)query 11 → key 1: allowedquery 11 → key 2: allowedquery 11 → key 3: allowedquery 11 → key 4: allowedquery 11 → key 5: allowedquery 11 → key 6: allowedquery 11 → key 7: allowedquery 11 → key 8: allowedquery 11 → key 9: allowedquery 11 → key 10: allowedquery 11 → key 11: allowedquery 11 → key 12: allowedquery 11 → key 13: blocked (future block)query 11 → key 14: blocked (future block)query 11 → key 15: blocked (future block)query 11 → key 16: blocked (future block)query 12 → key 1: allowedquery 12 → key 2: allowedquery 12 → key 3: allowedquery 12 → key 4: allowedquery 12 → key 5: allowedquery 12 → key 6: allowedquery 12 → key 7: allowedquery 12 → key 8: allowedquery 12 → key 9: allowedquery 12 → key 10: allowedquery 12 → key 11: allowedquery 12 → key 12: allowedquery 12 → key 13: blocked (future block)query 12 → key 14: blocked (future block)query 12 → key 15: blocked (future block)query 12 → key 16: blocked (future block)query 13 → key 1: allowedquery 13 → key 2: allowedquery 13 → key 3: allowedquery 13 → key 4: allowedquery 13 → key 5: allowedquery 13 → key 6: allowedquery 13 → key 7: allowedquery 13 → key 8: allowedquery 13 → key 9: allowedquery 13 → key 10: allowedquery 13 → key 11: allowedquery 13 → key 12: allowedquery 13 → key 13: allowedquery 13 → key 14: allowedquery 13 → key 15: allowedquery 13 → key 16: allowedquery 14 → key 1: allowedquery 14 → key 2: allowedquery 14 → key 3: allowedquery 14 → key 4: allowedquery 14 → key 5: allowedquery 14 → key 6: allowedquery 14 → key 7: allowedquery 14 → key 8: allowedquery 14 → key 9: allowedquery 14 → key 10: allowedquery 14 → key 11: allowedquery 14 → key 12: allowedquery 14 → key 13: allowedquery 14 → key 14: allowedquery 14 → key 15: allowedquery 14 → key 16: allowedquery 15 → key 1: allowedquery 15 → key 2: allowedquery 15 → key 3: allowedquery 15 → key 4: allowedquery 15 → key 5: allowedquery 15 → key 6: allowedquery 15 → key 7: allowedquery 15 → key 8: allowedquery 15 → key 9: allowedquery 15 → key 10: allowedquery 15 → key 11: allowedquery 15 → key 12: allowedquery 15 → key 13: allowedquery 15 → key 14: allowedquery 15 → key 15: allowedquery 15 → key 16: allowedquery 16 → key 1: allowedquery 16 → key 2: allowedquery 16 → key 3: allowedquery 16 → key 4: allowedquery 16 → key 5: allowedquery 16 → key 6: allowedquery 16 → key 7: allowedquery 16 → key 8: allowedquery 16 → key 9: allowedquery 16 → key 10: allowedquery 16 → key 11: allowedquery 16 → key 12: allowedquery 16 → key 13: allowedquery 16 → key 14: allowedquery 16 → key 15: allowedquery 16 → key 16: allowed
16 query tokens
16 key/value tokens

4 × 4 block tiles (B = D = 4).

Purple: attended. Gray: not attended.

Hover or focus a square to inspect a query–key pair.
KV cache after this step
Completed blocks in cacheB0 · B1
Cached tokens8
Next active diffusion-blockB2
Filled sequence positions10 / 16
p = fixed prompt token ? = masked token g = decoded token cached KV

Process max prefill length. 10 of 16 positions filled. B2, 2 masked positions remain.

Paged Attention

To efficiently manage memory for many sequences of variable length, CDE uses paged attention: each request’s KV cache is allocated in fixed-size pages that don’t need to be contiguous in memory. The page table remaps logical BlockMasks onto physical memory pages, so memory is only allocated for the sequence length actually in use. This approach, adapted from PyTorch’s example and Flex-nano-vLLM (with stricter error checking and other customizations), removes the need to copy the KV cache repeatedly, allows concurrent requests with different sequence lengths without padding, and enables memory-intensive features like concept attribution (Serving Concept Attribution).

Dynamic Batching

So far, we have explained how to serve one request at a time (batch size 1). A natural way to increase throughput is to serve several requests together. At batch size 1, each decode step performs one forward pass through the 8B weights to process a single 64-token block, so the GPU is underused. Batching shares each forward pass across several requests in a batch, using the graph compiled for that batch size. The batch also advances to the next block only when every request has finished its current block. Batching is still dynamic: at each block boundary, finished requests leave the batch and new requests can join it. This design keeps the implementation simple, but ties inter-block latency to the slowest request. Batching raises throughput to 61.72 tok/s at batch size 4.

Batch sizeTime to first block (s)Inter-block latency (s)Time to last block (s)Throughput (tok/s)
10.690.781.7244.38
21.511.142.8756.09
32.381.594.3156.80
43.031.955.4961.72

From batch size 1 to 4, time-to-last-block increases 3.2× while throughput increases only 1.4×. The following table examines why throughput scales so poorly.

Batch sizeTime per forward pass (ms)Avg. forward passes per decode callGPU waiting on CPU (ms)CPU waiting on GPU (ms)
120.3737.23.3416.79
433.7161.45.4928.15

From batch size 1 to 4, each forward pass becomes 1.7× slower, and each decode call needs 1.7× more forward passes, because the batch waits for its slowest request to finish the block. The GPU sits idle as it waits for the CPU to compute concept attributions and sample (which is implemented as a loop over requests), growing 1.6× in step with the forward pass.

Letting each request finish its blocks independently of other requests should give clear improvements.

Results on GSM8K

We compare CDE with Diffulex and Fast-dLLM on GSM8K, first with a single request and then with four concurrent requests. For the other engines, we report both single-token sampling (ST), which matches CDE’s one token per step, and each engine’s native multi-token sampling (N). Fast-dLLM always runs a single batch, so its four-request results are its single-request results.

Single request. CDE has the lowest time to first block (0.69 s) and time to last block (1.72 s) of any configuration, and the second-lowest inter-block latency, behind only Fast-dLLM N, while always serving concept attributions and steering. Its single-request throughput (44.38 tok/s) is close to Diffulex ST (48.05 tok/s). The native configurations are faster, Diffulex N at 79.47 tok/s and Fast-dLLM N at 64.49 tok/s, because they sample several tokens per step (Limitations).

Single-request comparison of time to first block, inter-block latency, time to last block, and throughput for CDE, Diffulex, and Fast-dLLM

Single-request (batch size 1) latency and throughput on GSM8K.

Four requests. With four concurrent requests, CDE falls behind. Its throughput (61.72 tok/s) is the lowest of the configurations shown, compared with 102.06 tok/s for Diffulex ST, and its time to first block (3.03 s) is the highest. Its inter-block latency (1.95 s) is second only to Fast-dLLM N, which runs a single batch. This is the batch-scaling cost described in Dynamic Batching: the whole batch waits for its slowest request at every block.

Four-request comparison of time to first block, inter-block latency, time to last block, and throughput for CDE, Diffulex, and Fast-dLLM

Four-request latency and throughput on GSM8K. Fast-dLLM (†) always runs a single batch.

Limitations

Multi-token sampling. Most diffusion serving engines can sample several tokens per step, which reduces the number of forward passes at the cost of accuracy on most benchmarks. To maximize accuracy, CDE currently samples one token per forward pass.

Batch scaling. Because a batch moves to the next block only when every request has finished the current one, throughput grows slowly with batch size (Dynamic Batching). Letting each request advance through its blocks independently is the clearest next improvement.

Conclusion

In this post, we described the Causal Diffusion Engine (CDE), which serves Steerling-8B on our Clarity platform with single-request latency and throughput competitive with other diffusion serving engines. Unlike those engines, CDE returns concept attributions with every generated token and accepts steering on every request, so that the interpretability built into Steerling remains available in production.

Several lessons from building CDE apply beyond Steerling:

Finally, batching requests block by block keeps the implementation simple, but ties every batch to its slowest request. At batch size 4, throughput rises only 1.4× to 61.72 tok/s, in part because sampling and attribution run as a loop over requests on the CPU. Allowing each request to advance through its blocks independently is the natural next step. More broadly, CDE shows that interpretability does not have to come at the cost of serving performance: a model can be easy to audit, align, and understand, and still be fast enough to use.