File size: 13,460 Bytes
e5c09aa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | # How ROMA Works β Architecture & Implementation
This document explains ROMA (["Real-time Omni-Multimodal Assistant"](https://arxiv.org/abs/2601.10323))
in plain terms: what problem it solves, the model design, and **where each piece lives in this
codebase**. It is written from the actual code, with clickable pointers to the relevant files.
---
## 1. The problem in one paragraph
A normal video-LLM is **reactive**: you give it a whole video + a question, it answers once.
ROMA is **streaming and proactive**: it watches audio + video as they arrive, *second by second*,
and decides **on its own when to speak** β to fire an alert the moment a condition is met, to
narrate an event right when it finishes, or to answer a spoken question at the right time. Two
hard sub-problems fall out of this:
1. **Granularity mismatch** β audio is *dense* (a continuous waveform), video is *sparse*
(a few discrete frames per second). They must be fused on one shared timeline.
2. **When to speak** β the model must continuously judge "should I respond *now*?" without a user
pressing enter. ROMA's answer is a tiny extra classifier called the **Speak Head**.
---
## 2. The base model: Qwen2.5-Omni-7B
ROMA is **not trained from scratch**. It starts from **Qwen2.5-Omni-7B**, an omni-modal model with
two cooperating sub-models (you can see both referenced in the merge tooling at
[scripts/merger_new_module.py](scripts/merger_new_module.py#L23-L31)):
```
Qwen2_5OmniModel
βββ thinker (Qwen2_5OmniThinkerForConditionalGeneration)
β understands audio + video + text, generates TEXT
β β ROMA adds the "Speak Head" here
βββ talker (turns the thinker's output into SPEECH tokens; uses spk_dict.pt voices)
```
- The **thinker** is the multimodal brain: it ingests interleaved audio/video/text tokens and
produces text. ROMA's new modules are bolted onto the thinker.
- The **talker** produces audio so ROMA can *speak* its responses. ROMA leaves it essentially as-is
(the `spk_dict.pt` speaker dictionary is carried along during merging,
[merger_new_module.py:73-79](scripts/merger_new_module.py#L73-L79)).
> Two repos, one model. The **model internals** (the Qwen2.5-Omni classes, the Speak-Head forward
> pass, the interleaved-RoPE position function) live in a **custom `transformers` fork**,
> `git+https://github.com/Eureka-Maggie/transformers.git@roma_patch` (pinned in
> [requirements.txt:205](requirements.txt#L205)). **This repo** (a fork of *LLaMA-Factory*) holds
> everything *around* the model: data formatting, the streaming chat template, training, and the
> demo/inference glue. When you see `model.thinker.gate_mixer` below, the *class* is defined in the
> fork; the *call site* is in this repo.
---
## 3. The key idea #1 β "synchronized multimodal units" (one packet per second)
Instead of feeding one giant audio blob and one giant video blob, ROMA slices the stream into
**1-second units** and interleaves the two modalities *inside each second*. This is done in the
multimodal plugin, [src/llamafactory/data/mm_plugin.py](src/llamafactory/data/mm_plugin.py#L1836-L1990).
How the alignment works:
- A common clock: `MODEL_TIME_UNITS_PER_SECOND = 25`
([mm_plugin.py:1859](src/llamafactory/data/mm_plugin.py#L1859)). Everything is converted to these
units so audio and video share one timeline.
- **Audio** is encoded to ~25 tokens/second (the dense signal, downsampled by the audio encoder β
see the length formula at [mm_plugin.py:1844-1847](src/llamafactory/data/mm_plugin.py#L1844-L1847)).
- **Video** frames (sampled at `video_fps = 2`) become a grid of tokens each; each frame's tokens
are stamped with their real time `frame_index Γ video_sec_per_grid Γ 25`
([mm_plugin.py:1887-1898](src/llamafactory/data/mm_plugin.py#L1887-L1898)).
- `processor.get_chunked_index(...)` then cuts both token streams into per-second chunks
([mm_plugin.py:1917-1928](src/llamafactory/data/mm_plugin.py#L1917-L1928)), and each second is
emitted as **one packet** with this exact layout
([mm_plugin.py:1965-1972](src/llamafactory/data/mm_plugin.py#L1965-L1972)):
```
<|vision_bos|><|audio_bos|> [ video tokens for this second ][ audio tokens for this second ] <|audio_eos|><|vision_eos|>
```
That single, repeated structure is the "synchronized multimodal unit". Dense audio and discrete
video for the *same* second sit side-by-side, so the model never has to guess which audio goes with
which frame. Positions are then assigned with an **interleaved RoPE** index
(`model.thinker.get_interleaved_rope_index(...)`, called at
[gradio/proactive_gradio.py:225-232](gradio/proactive_gradio.py#L225-L232)) that orders audio and
video by time rather than by modality.
---
## 4. The key idea #2 β the Speak Head ("when to speak", decoupled from "what to say")
This is ROMA's headline contribution. It is a **tiny binary classifier** attached to the thinker
that, every second, outputs a probability "should I speak now?". Critically, it is **separate from**
the thinker's normal language-model head that decides *what* words to say β that's the "decoupling
of response initiation from generation" the paper describes.
It has two parts (the structure is visible where the merge tool re-creates them,
[merger_new_module.py:85-105](scripts/merger_new_module.py#L85-L105), and where inference calls
them, [gradio/proactive_gradio.py:259-277](gradio/proactive_gradio.py#L259-L277)):
1. **`gate_mixer`** β a learnable mixer over the last few transformer layers. It holds `K` logits
(`K = len(gate_layer_ids)`, default the **last 4 layers** `[-4, -3, -2, -1]`) and returns
`softmax(logits)` as mixing weights. It blends the **last token's** hidden state across those
layers:
```
h_mix = Ξ£_k w_k Β· hidden_state[layer_k][:, -1, :] # w = softmax(gate_mixer.logits)
```
2. **`gate_head`** β a small MLP that maps `h_mix` to a single number (a logit). The released model
uses the "pro" variant: `gate_head_pro_fc1 β activation β gate_head_pro_fc2`
([proactive_gradio.py:271-275](gradio/proactive_gradio.py#L271-L275)). A sigmoid turns the logit
into a probability:
```
p_speak = sigmoid( gate_head_pro_fc2( act( gate_head_pro_fc1( h_mix ) ) ) )
```
Then a simple rule fires the response
([proactive_gradio.py:283-288](gradio/proactive_gradio.py#L283-L288)):
```
if p_speak > THRESHOLD: -> speak (alert / narrate / answer)
else: -> stay silent
```
The threshold is task-dependent: **0.6** for proactive alerts
([proactive_gradio.py:20](gradio/proactive_gradio.py#L20)) and **0.975** for narration
([narration_gradio.py](gradio/narration_gradio.py)) β narration is stricter so it only speaks at
clear event boundaries.
Why this design is nice: the gate is *lightweight* (a few thousand parameters reading existing
hidden states), so checking "should I speak?" every second is cheap, and it doesn't disturb the
thinker's generation quality.
---
## 5. Putting it together β the real-time inference loop
The clearest end-to-end implementation is the proactive demo,
[gradio/proactive_gradio.py](gradio/proactive_gradio.py#L88-L292). One full pass:
```
load Qwen2_5OmniModel (bf16, flash_attention_2) # proactive_gradio.py:27-34
build per-second multimodal units via the template # :122-144 (uses streaming_turn template)
past_key_values = None # KV cache β makes it incremental/streaming
for each 1-second chunk:
wait until this second actually arrives (real-time) # :195-197 (time.sleep keeps it ~1 fps)
slice this second's video tokens + audio mel frames # :200-222 (audio: 100 mel frames/sec)
compute interleaved-RoPE positions, shift by KV pos # :225-239
out = model.thinker(..., past_key_values, use_cache, output_hidden_states) # :254-255
p_speak = SpeakHead(out.hidden_states) # :259-277 (gate_mixer + gate_head)
past_key_values = out.past_key_values # :279 carry the cache forward
if p_speak > THRESHOLD: emit alert # :283-288
```
Two things make it *streaming* rather than batch:
- **KV cache** (`past_key_values`): each second only the *new* chunk's tokens are forwarded; the
past is reused. Cost per step stays roughly constant instead of growing with video length.
- **Real-time pacing**: the loop sleeps so it advances ~1 second of input per wall-clock second
([:195-197](gradio/proactive_gradio.py#L195-L197)), mimicking a live feed.
The three demos differ only in the "what happens when the gate fires" part:
- [proactive_gradio.py](gradio/proactive_gradio.py) β fire an **alert** when a spoken condition is met.
- [narration_gradio.py](gradio/narration_gradio.py) β **narrate** the event that just ended.
- [mme_gradio.py](gradio/mme_gradio.py) β **answer** a multimodal question (reactive).
---
## 6. How it's trained
Training config: [yamls/train.yaml](yamls/train.yaml). It is **full supervised fine-tuning** of
Qwen2.5-Omni-7B with DeepSpeed ZeRO-3, FlashAttention-2, Liger kernels, bf16, the vision tower
frozen, lr `1e-5`, `max_steps: 6000`, on a **streaming** dataset (`streaming: true`,
interleaved `abl_all_1, abl_all_2`). The entry point on a multi-GPU node is
[sh/train.sh](sh/train.sh) β `launcher.py` β `run_exp`; the single-GPU debug path is
[debug_sft_singlegpu.py](debug_sft_singlegpu.py).
What the model learns comes from how the **labels** are built, in
[src/llamafactory/data/mm_plugin.py](src/llamafactory/data/mm_plugin.py#L1901-L1990) under the
`streaming_mix` template ([template.py:1630-1647](src/llamafactory/data/template.py#L1630-L1647)).
Each second gets a target:
- The dataset gives "say *this text* at time *t*" pairs; these are bucketed into
`answers_at_second` ([mm_plugin.py:1901-1914](src/llamafactory/data/mm_plugin.py#L1901-L1914)).
- A second **with** a target β the model should *speak* that content (gate label β 1).
- A second **with no** target β for proactive alerts the gold output is literally `"no"`
(encoded in the system prompt,
[template.py:1636-1641](src/llamafactory/data/template.py#L1636-L1641)) β gate label β 0.
So one objective trains **both** behaviors at once: the **Speak Head** learns the binary
speak/stay-silent decision per second, while the **thinker's LM head** learns to produce the right
content when it *does* speak. The system prompt also encodes the task rules β narrate only at event
transitions; for alerts, output the specified text (or `"alert"`) only when the condition holds,
otherwise `"no"`. The paper's "two-stage streaming curriculum" governs the order/mix in which these
streaming examples are presented.
The new gate parameters are flagged as trainable add-ons via `additional_target: gate_head,gate_mixer`
(and the freeze-mode variant `gate_head_pro_fc1,gate_head_pro_fc2,gate_mixer`) in
[yamls/train.yaml](yamls/train.yaml#L13-L24).
---
## 7. From trained weights to the released checkpoint
After full fine-tuning you have a `thinker` that contains the new `gate_*` modules. The merge tool
[scripts/merger_new_module.py](scripts/merger_new_module.py#L152-L205) (`save_full`):
1. Loads the fine-tuned thinker (and, if needed, back-fills the `gate_head` / `gate_mixer` tensors
straight out of the safetensors shards β [:108-149](scripts/merger_new_module.py#L108-L149)).
2. Drops it into a fresh top-level `Qwen2_5OmniModel` (`base_model.thinker = thinker`).
3. Saves the whole thing as sharded safetensors + processor, copying `spk_dict.pt` along.
That merged artifact is what you download from HuggingFace (`EurekaTian/ROMA`) and point the demos at
via `whole_model/model`.
---
## 8. Mental model / cheat-sheet
| Concept | What it is | Where in the code |
|---|---|---|
| Base model | Qwen2.5-Omni-7B (`thinker` + `talker`) | fork `transformers@roma_patch`; used in [merger_new_module.py](scripts/merger_new_module.py#L23-L31) |
| Synchronized unit | 1-second packet interleaving video + audio tokens | [mm_plugin.py:1965-1972](src/llamafactory/data/mm_plugin.py#L1965-L1972) |
| Shared clock | 25 model-time-units per second | [mm_plugin.py:1859](src/llamafactory/data/mm_plugin.py#L1859) |
| Interleaved RoPE | time-ordered positions for audio+video | call at [proactive_gradio.py:225](gradio/proactive_gradio.py#L225) |
| **Speak Head** | gate_mixer (layer blend) + gate_head (MLPβsigmoid) β p(speak) | [proactive_gradio.py:259-277](gradio/proactive_gradio.py#L259-L277), [merger_new_module.py:85-105](scripts/merger_new_module.py#L85-L105) |
| Streaming loop | KV-cache + real-time pacing, gate checked each second | [proactive_gradio.py:193-292](gradio/proactive_gradio.py#L193-L292) |
| Streaming template + labels | per-second targets; "no" when silent | [template.py:1630-1647](src/llamafactory/data/template.py#L1630-L1647), [mm_plugin.py:1901-1990](src/llamafactory/data/mm_plugin.py#L1901-L1990) |
| Training recipe | full SFT, ZeRO-3, fa2, streaming dataset | [yamls/train.yaml](yamls/train.yaml) |
> Note on accuracy: line numbers point at the code as cloned. The Speak-Head *module classes*,
> the Qwen2.5-Omni model code, and `get_interleaved_rope_index` are defined in the
> `Eureka-Maggie/transformers@roma_patch` fork, not in this repo β this repo calls into them.
> If you want to read the gate's exact `forward`/init, look in that fork's
> `modeling_qwen2_5_omni.py`.
|