google-deepmind / google-deepmind/gemma

[Bug] Hackable Diffusion SFT semantics differ from the DiffusionGemma Technical Report

Open
#773 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
5.7k
Forks
1k
Avg merge
10h 33m
Merged PRs (30d)
2

Description

# [Bug] Hackable Diffusion SFT semantics differ from the DiffusionGemma Technical Report

## Summary

The released Hackable Diffusion Adapter SFT path appears to differ from the
training semantics described in the DiffusionGemma Technical Report in two
places:

1. disabling self-conditioning produces a zero-logit soft embedding rather
than the report-defined zero self-conditioning signal;
2. multi-canvas SFT decodes the full response after moving the KV-cache cursor
to the selected canvas, which misaligns decoder K/V writes whenever
`selected_canvas_idx > 0`.

I verified this against:

- repository commit:
`7b785991bd78626c73b317eb43fdbb6c292f7b9c`
- DiffusionGemma Technical Report v1:
https://arxiv.org/abs/2608.00146

Could you please confirm whether these differences are intentional, or whether
the released SFT adapter should follow the report semantics?

## 1. Self-conditioning OFF is not an exact zero signal

The report states that, during SFT, 50% of data points use a
self-conditioning state computed by a previous forward pass, while the other
50% have:

```text
z_t = 0
```

See Section 8 / Equation 13, page 23:

https://arxiv.org/pdf/2608.00146#page=23

The current SFT implementation instead selects zero vocabulary logits for
disabled examples:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/diffusion/hackable_diffusion_adapter/hd/sft_model.py#L397-L413

```python
sc_logits = converted_first_pass['logits']
zero_logits = jnp.zeros_like(sc_logits)
...
sc_logits = jnp.where(do_self_cond, sc_logits, zero_logits)
```

The wrapper then always converts these logits into soft token embeddings:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/diffusion/hackable_diffusion_adapter/hd/hd_gemma_network.py#L220-L239

```python
if sc_logits is None:
sc_logits = jnp.zeros(...)
sc_embeddings = self.gemma_model.embedder.encode_logits(sc_logits)
```

`encode_logits` applies softmax and an embedding-table projection:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/gm/nn/gemma4/_modules.py#L140-L155

Therefore, for zero logits:

```text
softmax(0) = [1/V, ..., 1/V]

encode_logits(0)
= sqrt(d) * (1/V) * sum_v E_v
```

This is generally the vocabulary-mean embedding, not the exact zero vector
specified by the report. It also affects the first denoising pass because
`sc_logits is None` is converted into zero logits and then encoded in the same
way.

### Expected behavior

The SC-disabled branch should provide an exact zero embedding/signal after the
logit-to-embedding conversion boundary, for example by applying a per-example
mask to `sc_embeddings`:

```python
sc_embeddings = embedder.encode_logits(sc_logits)
sc_embeddings = jnp.where(
self_conditioning_mask[..., None],
sc_embeddings,
jnp.zeros_like(sc_embeddings),
)
```

When no previous logits are supplied, the first pass should likewise use an
exact zero self-conditioning embedding.

## 2. Multi-canvas SFT misaligns decoder KV-cache writes

The technical report describes selecting one canvas `k` and training its
denoising loss using:

```text
current noisy canvas x_t
+ KV cache H for the prompt
+ previous uncorrupted canvases 1 ... k-1
```

See:

- Section 4, page 12:
https://arxiv.org/pdf/2608.00146#page=12
- Section 8 / Equation 13, page 23:
https://arxiv.org/pdf/2608.00146#page=23

The current implementation corrupts the entire `K * C` response before
selecting a canvas:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/diffusion/hackable_diffusion_adapter/hd/sft_model.py#L302-L334

```python
time = self.time_sampler(..., x0)
xt, target_info = self.corruption_process.corrupt(..., x0, time)
...
selected_canvas_idx = jax.random.randint(...)
```

The encoder then sets:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/diffusion/hackable_diffusion_adapter/hd/sft_model.py#L211-L215

```python
end_index = prompt_len + selected_canvas_idx * canvas_size
```

However, `sft_decode` still submits the full
`total_canvas_len = K * C` query and uses positions for the full response:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/diffusion/hackable_diffusion_adapter/hd/sft_model.py#L129-L145

```python
num_queries = total_canvas_len
canvas_positions = positions[:, prompt_len:]
...
gemma_network(xt=xt, ...)
```

The underlying attention cache writes queries starting at `end_index`,
wrapping modulo the cache size:

https://github.com/google-deepmind/gemma/blob/7b785991bd78626c73b317eb43fdbb6c292f7b9c/gemma/gm/nn/gemma4/_modules.py#L298-L313

```python
indices = (
end_index[:, None] + jnp.arange(seq_len)[None, :]
) % cache_size
```

### Minimal index example

Let:

```text
prompt_len P = 4
canvas_size C = 2
num_canvases K = 3
selected canvas k = 1
cache_size = P + K*C = 10
```

Then:

```text
end_index = P + k*C = 6
seq_len = K*C = 6
write indices = (6 + arange(6)) % 10
= [6, 7, 8, 9, 0, 1]
```

This maps the noisy queries as follows:

```text
noisy canvas 0 -> canvas 1 cache slots
noisy canvas 1 -> canvas 2 cache slots
noisy canvas 2 -> prompt cache slots
```

Meanwhile, the decoder attention mask continues to interpret physical cache
slots as:

```text
[0, P) = prompt
[P + j*C, P+(j+1)*C) = canvas j
```

Consequences for `selected_canvas_idx > 0` include:

- prompt K/V can be overwritten by later noisy canvases;
- the selected noisy canvas K/V is written into later-canvas slots;
- the selected canvas may not see its own noisy K/V under the existing mask;
- the forward pass can condition on displaced noisy states instead of
`clean previous canvases + noisy selected canvas`.

Masking the loss to the selected canvas does not repair the forward-pass cache
layout.

This issue is hidden when `num_canvases == 1` or when canvas 0 is selected.

### Expected behavior

A report-aligned path could:

1. sample the canvas index first;
2. gather only the selected clean canvas;
3. sample time and corrupt only that canvas;
4. use a decoder query of exactly `canvas_size`;
5. write its K/V only to the selected canvas's absolute cache slots;
6. expose only the prompt, clean previous canvases, and the selected noisy
canvas through the attention mask.

Conceptually:

```text
Decoder input: noisy C_k
Visible KV context: prompt + clean C_ 0`:

- verify decoder query length is exactly `canvas_size`;
- verify decoder writes only to the selected canvas slots;
- verify prompt K/V remains unchanged;
- verify changing a previous clean canvas changes selected-canvas logits;
- verify changing a future canvas does not change selected-canvas logits;
- compare the multi-canvas result with an equivalent single-canvas reference
execution.

## Questions

1. Is the released Hackable Diffusion SFT adapter intended to reproduce the
Technical Report's SFT objective?
2. If the current full-response decoder behavior is intentional, could the
semantic difference be documented?
3. If it is not intentional, would a patch implementing selected-canvas-only
corruption/decode and an exact-zero SC mask be welcome?

Contributor guide

Open the contributing guide

Research direction

Start in gemma/diffusion/hackable_diffusion_adapter/hd/sft_model.py, tracing sft_decode, canvas selection, corruption, and self-conditioning into hd_gemma_network.py; inspect cache indexing in gemma/gm/nn/gemma4/_modules.py. Add regression coverage for exact-zero disabled self-conditioning and selected-canvas query/cache behavior, then verify prompt and future-canvas state are unaffected while previous clean canvases remain visible.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend, machine-learning
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.