Switch to using DistributedImageModel

This commit is contained in:
ciaranbor
2026-01-06 10:51:21 +00:00
parent cb8079525c
commit e3d89b8d63
5 changed files with 26 additions and 1036 deletions
@@ -1,364 +0,0 @@
from math import ceil
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional
import mlx.core as mx
from mflux.callbacks.callbacks import Callbacks
from mflux.config.config import Config
from mflux.config.model_config import ModelConfig
from mflux.config.runtime_config import RuntimeConfig
from mflux.models.common.latent_creator.latent_creator import Img2Img, LatentCreator
from mflux.models.flux.latent_creator.flux_latent_creator import FluxLatentCreator
from mflux.models.flux.model.flux_text_encoder.prompt_encoder import PromptEncoder
from mflux.models.flux.variants.txt2img.flux import Flux1
from mflux.utils.array_util import ArrayUtil
from mflux.utils.exceptions import StopImageGenerationException
from mflux.utils.image_util import ImageUtil
from PIL import Image
from tqdm import tqdm
from exo.shared.types.worker.instances import BoundInstance
from exo.shared.types.worker.shards import PipelineShardMetadata
from exo.worker.download.download_utils import build_model_path
from exo.worker.engines.mflux.pipefusion.distributed_denoising import (
DistributedDenoising,
)
from exo.worker.engines.mlx.utils_mlx import mlx_distributed_init, mx_barrier
from exo.worker.runner.bootstrap import logger
class DistributedFlux1:
"""
Wrapper for Flux1 that attaches distributed group and shard metadata.
This wrapper enables the generation runtime to access distributed context
(group, rank, world_size, shard boundaries).
"""
__slots__ = ("_model", "_group", "_shard_metadata", "_variant")
_model: Flux1
_group: Optional[mx.distributed.Group]
_shard_metadata: PipelineShardMetadata
_variant: str
_steps_map: dict[str, dict[str, int]] = {
"schnell": {"low": 1, "medium": 2, "high": 4},
"dev": {"low": 10, "medium": 25, "high": 50},
}
def __init__(
self,
model_id: str,
local_path: Path,
shard_metadata: PipelineShardMetadata,
group: Optional[mx.distributed.Group] = None,
quantize: int | None = None,
):
"""
Initialize DistributedFlux1 directly from configuration.
Args:
model_id: The model identifier (e.g., "black-forest-labs/FLUX.1-schnell")
local_path: Path to the local model weights
shard_metadata: Pipeline shard metadata with layer assignments
group: MLX distributed group for multi-node coordination (None for single-node)
quantize: Optional quantization bit width
"""
variant = model_id.split("-")[-1]
model = Flux1(
model_config=ModelConfig.from_name(model_name=model_id, base_model=None),
local_path=str(local_path),
quantize=quantize,
)
if group is not None:
# Apply pipeline parallelism by wrapping the transformer
model.transformer = DistributedDenoising( # type: ignore[assignment]
transformer=model.transformer,
group=group,
shard_metadata=shard_metadata,
num_sync_steps=ceil(self._steps_map[variant]["medium"] / 8),
)
logger.info("Applied pipefusion transformations")
mx.eval(model.parameters())
# TODO: Do we need this?
mx.eval(model)
# Synchronize processes before generation to avoid timeout
mx_barrier(group)
logger.info(f"Flux transformer sharded for rank {group.rank()}")
else:
logger.info("Single-node Flux initialization")
object.__setattr__(self, "_model", model)
object.__setattr__(self, "_group", group)
object.__setattr__(self, "_shard_metadata", shard_metadata)
object.__setattr__(self, "_variant", variant)
@classmethod
def from_bound_instance(cls, bound_instance: BoundInstance) -> "DistributedFlux1":
"""
Create DistributedFlux1 from a BoundInstance.
This factory method extracts model configuration from the bound instance
and handles distributed initialization if needed.
Args:
bound_instance: The bound instance containing model and shard info
Returns:
Initialized DistributedFlux1 ready for inference
"""
model_id = bound_instance.bound_shard.model_meta.model_id
model_path = build_model_path(model_id)
shard_metadata = bound_instance.bound_shard
if not isinstance(shard_metadata, PipelineShardMetadata):
raise ValueError("Expected PipelineShardMetadata for Flux")
is_distributed = (
len(bound_instance.instance.shard_assignments.node_to_runner) > 1
)
if is_distributed:
logger.info("Starting distributed init for Flux")
group = mlx_distributed_init(bound_instance)
else:
group = None
return cls(
model_id=model_id,
local_path=model_path,
shard_metadata=shard_metadata,
group=group,
)
@property
def model(self) -> Flux1:
"""The underlying Flux1 model."""
return self._model
@property
def group(self) -> Optional[mx.distributed.Group]:
"""The MLX distributed group for this model (None for single-node)."""
return self._group
@property
def shard_metadata(self) -> PipelineShardMetadata:
"""Shard metadata containing layer assignments and device info."""
return self._shard_metadata
@property
def rank(self) -> int:
"""This device's rank in the distributed group."""
return self._shard_metadata.device_rank
@property
def world_size(self) -> int:
"""Total number of devices in the distributed group."""
return self._shard_metadata.world_size
@property
def is_first_stage(self) -> bool:
"""True if this device is the first stage in the pipeline."""
return self._shard_metadata.device_rank == 0
@property
def is_last_stage(self) -> bool:
"""True if this device is the last stage in the pipeline."""
return self._shard_metadata.device_rank == self._shard_metadata.world_size - 1
@property
def is_distributed(self) -> bool:
"""True if running in distributed mode (world_size > 1)."""
return self._shard_metadata.world_size > 1
# Delegate attribute access to the underlying model.
# Guarded with TYPE_CHECKING to prevent type checker complaints
# while still providing full delegation at runtime.
if not TYPE_CHECKING:
def __getattr__(self, name: str) -> Any:
return getattr(self._model, name)
def __setattr__(self, name: str, value: Any) -> None:
if name in ("_model", "_group", "_shard_metadata"):
object.__setattr__(self, name, value)
else:
setattr(self._model, name, value)
def generate(
self,
prompt: str,
height: int,
width: int,
quality: Literal["low", "medium", "high"] = "medium",
seed: int = 2,
) -> Optional[Image.Image]:
"""
Generate an image using the Flux1 model.
For distributed inference, only the first stage (rank 0) returns the image.
Other stages return None after participating in the pipeline.
Args:
prompt: Text description of the image to generate
height: Image height in pixels
width: Image width in pixels
quality: Generation quality ("low", "medium", "high")
seed: Random seed for reproducibility
Returns:
Generated PIL Image (rank 0) or None (other ranks)
"""
# Determine number of inference steps based on quality
steps = self._steps_map[self._variant][quality]
config = Config(num_inference_steps=steps, height=height, width=width)
image = self._generate_image(settings=config, prompt=prompt, seed=seed)
logger.info("generated image")
# Only rank 0 returns the actual image
if self.is_first_stage:
return image.image
def _generate_image(self, settings: Config, prompt: str, seed: int) -> Any:
"""
Internal image generation with the diffusion loop.
This method implements the core diffusion process with distributed
communication handled at the transformer level (via block wrappers
for now, will be moved to loop level for async pipeline).
"""
model = self._model
# 0. Create runtime config
config = RuntimeConfig(settings, model.model_config)
time_steps = tqdm(range(config.init_time_step, config.num_inference_steps))
# 1. Create initial latents (all nodes create the same latents with same seed)
latents = LatentCreator.create_for_txt2img_or_img2img(
seed=seed,
height=config.height,
width=config.width,
img2img=Img2Img(
vae=model.vae,
latent_creator=FluxLatentCreator,
image_path=config.image_path,
sigmas=config.scheduler.sigmas,
init_time_step=config.init_time_step,
),
)
# 2. Encode the prompt (all nodes encode to get consistent embeddings)
prompt_embeds, pooled_prompt_embeds = PromptEncoder.encode_prompt(
prompt=prompt,
prompt_cache=model.prompt_cache,
t5_tokenizer=model.t5_tokenizer,
clip_tokenizer=model.clip_tokenizer,
t5_text_encoder=model.t5_text_encoder,
clip_text_encoder=model.clip_text_encoder,
)
# (Optional) Call subscribers for beginning of loop
Callbacks.before_loop(
seed=seed,
prompt=prompt,
latents=latents,
config=config,
)
# 3. Main diffusion loop
for t in time_steps:
try:
latents = self._diffusion_step(
t=t,
config=config,
latents=latents,
prompt_embeds=prompt_embeds,
pooled_prompt_embeds=pooled_prompt_embeds,
)
# (Optional) Call subscribers in-loop
Callbacks.in_loop(
t=t,
seed=seed,
prompt=prompt,
latents=latents,
config=config,
time_steps=time_steps,
)
mx.eval(latents)
except KeyboardInterrupt: # noqa: PERF203
Callbacks.interruption(
t=t,
seed=seed,
prompt=prompt,
latents=latents,
config=config,
time_steps=time_steps,
)
raise StopImageGenerationException(
f"Stopping image generation at step {t + 1}/{len(time_steps)}"
) from None
# (Optional) Call subscribers after loop
Callbacks.after_loop(
seed=seed,
prompt=prompt,
latents=latents,
config=config,
)
# 4. Decode latents to image (all nodes decode for now)
latents = ArrayUtil.unpack_latents(
latents=latents, height=config.height, width=config.width
)
decoded = model.vae.decode(latents)
return ImageUtil.to_image(
decoded_latents=decoded,
config=config,
seed=seed,
prompt=prompt,
quantization=model.bits,
lora_paths=model.lora_paths,
lora_scales=model.lora_scales,
image_path=config.image_path,
image_strength=config.image_strength,
generation_time=time_steps.format_dict["elapsed"],
)
def _diffusion_step(
self,
t: int,
config: RuntimeConfig,
latents: mx.array,
prompt_embeds: mx.array,
pooled_prompt_embeds: mx.array,
) -> mx.array:
if self._group is None:
latents = config.scheduler.scale_model_input(latents, t)
noise = self._model.transformer(
t=t,
config=config,
hidden_states=latents,
prompt_embeds=prompt_embeds,
pooled_prompt_embeds=pooled_prompt_embeds,
)
if self._group is None:
latents = config.scheduler.step(
model_output=noise,
timestep=t,
sample=latents,
)
else:
latents = noise
return latents
@@ -6,7 +6,7 @@ from PIL import Image
from exo.shared.types.api import ImageGenerationTaskParams
from exo.shared.types.worker.runner_response import ImageGenerationResponse
from exo.worker.engines.mflux.distributed_flux import DistributedFlux1
from exo.worker.engines.mflux.distributed_model import DistributedImageModel
image_generation_stream = mx.new_stream(mx.default_device())
@@ -30,7 +30,7 @@ def parse_size(size_str: str | None) -> tuple[int, int]:
return (1024, 1024)
def warmup_mflux(model: DistributedFlux1) -> Image.Image | None:
def warmup_mflux(model: DistributedImageModel) -> Image.Image | None:
"""
Warmup the model by generating a small image.
@@ -46,11 +46,11 @@ def warmup_mflux(model: DistributedFlux1) -> Image.Image | None:
def mflux_generate(
model: DistributedFlux1,
model: DistributedImageModel,
task: ImageGenerationTaskParams,
) -> Generator[ImageGenerationResponse, None, None]:
"""
Generate an image using the DistributedFlux1 model.
Generate an image using the DistributedImageModel.
For distributed inference, only rank 0 yields the response.
Other ranks participate in the pipeline but yield nothing.
@@ -171,11 +171,11 @@ class DistributedDenoising:
def _create_patches(
self,
latents: mx.array,
runtime_config: RuntimeConfig,
config: RuntimeConfig,
) -> tuple[list[mx.array], list[tuple[int, int]]]:
# Calculate patch metadata using model config
latent_height = runtime_config.height // self.config.vae_scale_factor
latent_width = runtime_config.width // self.config.vae_scale_factor
latent_height = config.height // self.config.vae_scale_factor
latent_width = config.width // self.config.vae_scale_factor
patch_size = self.config.patch_size
patch_heights, _ = calculate_patch_heights(
@@ -191,7 +191,7 @@ class DistributedDenoising:
def _sync_pipeline(
self,
t: int,
runtime_config: RuntimeConfig,
config: RuntimeConfig,
hidden_states: mx.array,
prompt_embeds: mx.array,
pooled_prompt_embeds: mx.array,
@@ -199,7 +199,7 @@ class DistributedDenoising:
) -> mx.array:
prev_latents = hidden_states
hidden_states = runtime_config.scheduler.scale_model_input(hidden_states, t)
hidden_states = config.scheduler.scale_model_input(hidden_states, t)
# === PHASE 1: Embeddings ===
# First stage: compute embeddings
@@ -211,12 +211,12 @@ class DistributedDenoising:
# All stages need these for their blocks
text_embeddings = self.adapter.compute_text_embeddings(
t, pooled_prompt_embeds, self.transformer, runtime_config
t, pooled_prompt_embeds, self.transformer, config
)
image_rotary_embeddings = self.adapter.compute_rotary_embeddings(
prompt_embeds,
self.transformer,
runtime_config,
config,
kontext_image_ids=kontext_image_ids,
)
@@ -335,7 +335,7 @@ class DistributedDenoising:
hidden_states, text_embeddings, self.transformer
)
hidden_states = runtime_config.scheduler.step(
hidden_states = config.scheduler.step(
model_output=hidden_states,
timestep=t,
sample=prev_latents,
@@ -360,7 +360,7 @@ class DistributedDenoising:
def _async_pipeline(
self,
t: int,
runtime_config: RuntimeConfig,
config: RuntimeConfig,
patch_latents: list[mx.array],
token_indices: list[tuple[int, int]],
prompt_embeds: mx.array,
@@ -371,15 +371,15 @@ class DistributedDenoising:
assert self.single_kv_caches is not None
# TODO(ciaran): needed in general?
# hidden_states = runtime_config.scheduler.scale_model_input(hidden_states, t)
# hidden_states = config.scheduler.scale_model_input(hidden_states, t)
text_embeddings = self.adapter.compute_text_embeddings(
t, pooled_prompt_embeds, self.transformer, runtime_config
t, pooled_prompt_embeds, self.transformer, config
)
image_rotary_embeddings = self.adapter.compute_rotary_embeddings(
prompt_embeds,
self.transformer,
runtime_config,
config,
kontext_image_ids=kontext_image_ids,
)
@@ -503,7 +503,7 @@ class DistributedDenoising:
patch_img_only, text_embeddings, self.transformer
)
patch = runtime_config.scheduler.step(
patch = config.scheduler.step(
model_output=patch_img_only,
timestep=t,
sample=patch_prev,
@@ -521,7 +521,7 @@ class DistributedDenoising:
def __call__(
self,
t: int,
runtime_config: RuntimeConfig,
config: RuntimeConfig,
hidden_states: mx.array,
prompt_embeds: mx.array,
pooled_prompt_embeds: mx.array,
@@ -532,20 +532,18 @@ class DistributedDenoising:
if t < self.num_sync_steps:
latents = self._sync_pipeline(
t,
runtime_config,
config,
hidden_states,
prompt_embeds,
pooled_prompt_embeds,
kontext_image_ids,
)
else:
patch_latents, token_indices = self._create_patches(
hidden_states, runtime_config
)
patch_latents, token_indices = self._create_patches(hidden_states, config)
patch_latents = self._async_pipeline(
t,
runtime_config,
config,
patch_latents,
token_indices,
prompt_embeds,
@@ -555,7 +553,7 @@ class DistributedDenoising:
# Receive final patches from last rank
if (
t == runtime_config.num_inference_steps - 1
t == config.num_inference_steps - 1
and self.is_first_stage
and not self.is_last_stage
):
@@ -1,644 +0,0 @@
import mlx.core as mx
from mflux.models.flux.model.flux_transformer.common.attention_utils import (
AttentionUtils,
)
from mflux.models.flux.model.flux_transformer.joint_attention import JointAttention
from mflux.models.flux.model.flux_transformer.joint_transformer_block import (
JointTransformerBlock,
)
from mflux.models.flux.model.flux_transformer.single_block_attention import (
SingleBlockAttention,
)
from mflux.models.flux.model.flux_transformer.single_transformer_block import (
SingleTransformerBlock,
)
from exo.worker.engines.mflux.pipefusion.kv_cache import ImagePatchKVCache
class CachedJointAttention:
"""JointAttention that uses KV cache for PipeFusion.
Processes attention for a single image patch while attending to the full
sequence (text + all image patches) using cached K/V values.
"""
head_dim = 128
num_heads = 24
def __init__(self, attn: JointAttention):
"""Wrap an existing JointAttention module.
Args:
attn: The original JointAttention module (for weight access)
"""
self.attn = attn
self.num_heads = attn.num_heads
self.head_dim = attn.head_dimension
def __call__(
self,
norm_hidden: mx.array,
norm_encoder: mx.array,
image_rotary_emb: mx.array,
kv_cache: ImagePatchKVCache,
patch_start: int,
patch_end: int,
text_seq_len: int,
) -> tuple[mx.array, mx.array]:
"""Forward pass with KV caching for patch-based attention.
Args:
norm_hidden: Normalized image patch hidden states [B, patch_img_len, D]
norm_encoder: Normalized full text hidden states [B, text_len, D]
image_rotary_emb: Full rotary embeddings for [text + full_image]
kv_cache: KV cache to update and read from
patch_start: Start token index of this patch in image sequence
patch_end: End token index of this patch in image sequence
text_seq_len: Length of text sequence
Returns:
Tuple of (hidden_states_out, encoder_hidden_states_out) for the patch
"""
# 1. Compute Q, K, V for current image patch
img_query, img_key, img_value = AttentionUtils.process_qkv(
hidden_states=norm_hidden,
to_q=self.attn.to_q,
to_k=self.attn.to_k,
to_v=self.attn.to_v,
norm_q=self.attn.norm_q,
norm_k=self.attn.norm_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 2. Compute Q, K, V for text (always full)
txt_query, txt_key, txt_value = AttentionUtils.process_qkv(
hidden_states=norm_encoder,
to_q=self.attn.add_q_proj,
to_k=self.attn.add_k_proj,
to_v=self.attn.add_v_proj,
norm_q=self.attn.norm_added_q,
norm_k=self.attn.norm_added_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 3. Concatenate Q for attention output: [text, image_patch]
query = mx.concatenate([txt_query, img_query], axis=2)
# 4. Concatenate K, V for this patch: [text, image_patch]
patch_key = mx.concatenate([txt_key, img_key], axis=2)
patch_value = mx.concatenate([txt_value, img_value], axis=2)
# 5. Extract RoPE for [text + current_patch] positions
# RoPE shape is (1, 1, seq_len, head_dim/2, 2, 2), sequence is on axis 2
text_rope = image_rotary_emb[:, :, :text_seq_len, ...]
patch_img_rope = image_rotary_emb[
:, :, text_seq_len + patch_start : text_seq_len + patch_end, ...
]
patch_rope = mx.concatenate([text_rope, patch_img_rope], axis=2)
# 6. Apply RoPE to Q and K for current patch
query, patch_key = AttentionUtils.apply_rope(
xq=query, xk=patch_key, freqs_cis=patch_rope
)
# 7. Update cache with this patch's IMAGE K/V only (after RoPE)
# Text K/V is not cached - it's always fresh and the same for all patches
kv_cache.update_image_patch(
patch_start=patch_start,
patch_end=patch_end,
key=patch_key[:, :, text_seq_len:, :],
value=patch_value[:, :, text_seq_len:, :],
)
# 8. Get full K, V by concatenating fresh text K/V with cached image K/V
# Text K/V: fresh (just computed), Image K/V: fresh for current patch, stale for others
full_key, full_value = kv_cache.get_full_kv(
text_key=patch_key[:, :, :text_seq_len, :],
text_value=patch_value[:, :, :text_seq_len, :],
)
# 9. Compute attention: patch query attends to full K, V
# Query shape: [B, H, text_seq_len + patch_len, D]
# Key/Value shape: [B, H, text_seq_len + full_img_len, D]
# Output shape: [B, text_seq_len + patch_len, D]
batch_size = norm_hidden.shape[0]
attn_output = AttentionUtils.compute_attention(
query=query,
key=full_key,
value=full_value,
batch_size=batch_size,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 10. Extract outputs for text and image patch
encoder_hidden_states_out = attn_output[:, :text_seq_len, :]
hidden_states_out = attn_output[:, text_seq_len:, :]
# 11. Project outputs
hidden_states_out = self.attn.to_out[0](hidden_states_out)
encoder_hidden_states_out = self.attn.to_add_out(encoder_hidden_states_out)
return hidden_states_out, encoder_hidden_states_out
class CachedSingleBlockAttention:
"""SingleBlockAttention that uses KV cache for PipeFusion.
Processes attention for a single patch of concatenated [text + image]
while attending to the full sequence using cached K/V values.
"""
head_dim = 128
num_heads = 24
def __init__(self, attn: SingleBlockAttention):
"""Wrap an existing SingleBlockAttention module.
Args:
attn: The original SingleBlockAttention module (for weight access)
"""
self.attn = attn
self.head_dim = attn.head_dimension
self.num_heads = attn.num_heads
def __call__(
self,
norm_hidden: mx.array,
image_rotary_emb: mx.array,
kv_cache: ImagePatchKVCache,
patch_start: int,
patch_end: int,
text_seq_len: int,
) -> mx.array:
"""Forward pass with KV caching for patch-based attention.
Args:
norm_hidden: Normalized patch of [text + image] hidden states [B, text_len + patch_img_len, D]
image_rotary_emb: Full rotary embeddings for [text + full_image]
kv_cache: KV cache to update and read from
patch_start: Start token index of image patch (within image portion)
patch_end: End token index of image patch (within image portion)
text_seq_len: Length of text portion in hidden_states
Returns:
Attention output for the patch [B, text_len + patch_img_len, D]
"""
# 1. Compute Q, K, V for current patch
query, key, value = AttentionUtils.process_qkv(
hidden_states=norm_hidden,
to_q=self.attn.to_q,
to_k=self.attn.to_k,
to_v=self.attn.to_v,
norm_q=self.attn.norm_q,
norm_k=self.attn.norm_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 2. Extract RoPE for [text + current_image_patch] positions
# RoPE shape is (1, 1, seq_len, head_dim/2, 2, 2), sequence is on axis 2
text_rope = image_rotary_emb[:, :, :text_seq_len, ...]
patch_img_rope = image_rotary_emb[
:, :, text_seq_len + patch_start : text_seq_len + patch_end, ...
]
patch_rope = mx.concatenate([text_rope, patch_img_rope], axis=2)
# 3. Apply RoPE to Q and K
query, key = AttentionUtils.apply_rope(xq=query, xk=key, freqs_cis=patch_rope)
# 4. Update cache with this patch's IMAGE K/V only (after RoPE)
# Text K/V is not cached - it's always fresh and the same for all patches
kv_cache.update_image_patch(
patch_start=patch_start,
patch_end=patch_end,
key=key[:, :, text_seq_len:, :],
value=value[:, :, text_seq_len:, :],
)
# 5. Get full K, V by concatenating fresh text K/V with cached image K/V
# Text K/V: fresh (just computed), Image K/V: fresh for current patch, stale for others
full_key, full_value = kv_cache.get_full_kv(
text_key=key[:, :, :text_seq_len, :],
text_value=value[:, :, :text_seq_len, :],
)
# 6. Compute attention: patch query attends to full K, V
batch_size = norm_hidden.shape[0]
attn_output = AttentionUtils.compute_attention(
query=query,
key=full_key,
value=full_value,
batch_size=batch_size,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
return attn_output
class PatchedJointTransformerBlock:
"""Joint transformer block with KV caching for patch-based processing.
Wraps a JointTransformerBlock to process image patches while using
cached K/V values for the full sequence (text + all image patches).
"""
def __init__(self, block: JointTransformerBlock):
"""Wrap an existing JointTransformerBlock.
Args:
block: The original JointTransformerBlock (for weight access)
"""
self.block = block
self.cached_attn = CachedJointAttention(block.attn)
def __call__(
self,
patch_hidden: mx.array,
encoder_hidden_states: mx.array,
text_embeddings: mx.array,
image_rotary_emb: mx.array,
kv_cache: ImagePatchKVCache,
patch_start: int,
patch_end: int,
text_seq_len: int,
) -> tuple[mx.array, mx.array]:
"""Forward pass with KV caching for patch-based processing.
Args:
patch_hidden: Image patch hidden states [B, patch_img_len, D]
encoder_hidden_states: Full text hidden states [B, text_len, D]
text_embeddings: Time + pooled text conditioning
image_rotary_emb: Full rotary embeddings for [text + full_image]
kv_cache: KV cache to update and read from
patch_start: Start token index of this patch in image sequence
patch_end: End token index of this patch in image sequence
text_seq_len: Length of text sequence
Returns:
Tuple of (encoder_hidden_states, patch_hidden) after block processing
"""
# 1. Compute norms
norm_hidden, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.block.norm1(
hidden_states=patch_hidden,
text_embeddings=text_embeddings,
)
(
norm_encoder,
c_gate_msa,
c_shift_mlp,
c_scale_mlp,
c_gate_mlp,
) = self.block.norm1_context(
hidden_states=encoder_hidden_states,
text_embeddings=text_embeddings,
)
# 2. Compute attention with KV cache
attn_output, context_attn_output = self.cached_attn(
norm_hidden=norm_hidden,
norm_encoder=norm_encoder,
image_rotary_emb=image_rotary_emb,
kv_cache=kv_cache,
patch_start=patch_start,
patch_end=patch_end,
text_seq_len=text_seq_len,
)
# 3. Apply norm and feed forward
patch_hidden = JointTransformerBlock.apply_norm_and_feed_forward(
hidden_states=patch_hidden,
attn_output=attn_output,
gate_mlp=gate_mlp,
gate_msa=gate_msa,
scale_mlp=scale_mlp,
shift_mlp=shift_mlp,
norm_layer=self.block.norm2,
ff_layer=self.block.ff,
)
encoder_hidden_states = JointTransformerBlock.apply_norm_and_feed_forward(
hidden_states=encoder_hidden_states,
attn_output=context_attn_output,
gate_mlp=c_gate_mlp,
gate_msa=c_gate_msa,
scale_mlp=c_scale_mlp,
shift_mlp=c_shift_mlp,
norm_layer=self.block.norm2_context,
ff_layer=self.block.ff_context,
)
return encoder_hidden_states, patch_hidden
class PatchedSingleTransformerBlock:
"""Single transformer block with KV caching for patch-based processing.
Wraps a SingleTransformerBlock to process patches of concatenated [text + image]
while using cached K/V values for the full sequence.
"""
def __init__(self, block: SingleTransformerBlock):
"""Wrap an existing SingleTransformerBlock.
Args:
block: The original SingleTransformerBlock (for weight access)
"""
self.block = block
self.cached_attn = CachedSingleBlockAttention(block.attn)
def __call__(
self,
patch_hidden: mx.array,
text_embeddings: mx.array,
image_rotary_emb: mx.array,
kv_cache: ImagePatchKVCache,
patch_start: int,
patch_end: int,
text_seq_len: int,
) -> mx.array:
"""Forward pass with KV caching for patch-based processing.
Args:
patch_hidden: Patch of [text + image] hidden states [B, text_len + patch_img_len, D]
text_embeddings: Time + pooled text conditioning
image_rotary_emb: Full rotary embeddings for [text + full_image]
kv_cache: KV cache to update and read from
patch_start: Start token index of image patch (within image portion)
patch_end: End token index of image patch (within image portion)
text_seq_len: Length of text portion in hidden_states
Returns:
Output hidden states [B, text_len + patch_img_len, D]
"""
# 0. Establish residual connection
residual = patch_hidden
# 1. Compute norm
norm_hidden, gate = self.block.norm(
hidden_states=patch_hidden,
text_embeddings=text_embeddings,
)
# 2. Compute attention with KV cache
attn_output = self.cached_attn(
norm_hidden=norm_hidden,
image_rotary_emb=image_rotary_emb,
kv_cache=kv_cache,
patch_start=patch_start,
patch_end=patch_end,
text_seq_len=text_seq_len,
)
# 3. Apply feed forward and projection
hidden_states = self.block._apply_feed_forward_and_projection(
norm_hidden_states=norm_hidden,
attn_output=attn_output,
gate=gate,
)
return residual + hidden_states
class CachingJointTransformerBlock:
"""Joint transformer block that captures K/V for cache during sync mode.
Implements the full block computation while storing K/V in the cache for
subsequent async timesteps to use as stale values. Computes K/V only once.
"""
def __init__(self, block: JointTransformerBlock, kv_cache: ImagePatchKVCache):
"""Wrap an existing JointTransformerBlock with a KV cache.
Args:
block: The original JointTransformerBlock
kv_cache: KV cache to populate during forward pass
"""
self.block = block
self.kv_cache = kv_cache
self.attn = block.attn
self.num_heads = block.attn.num_heads
self.head_dim = block.attn.head_dimension
def __call__(
self,
hidden_states: mx.array,
encoder_hidden_states: mx.array,
text_embeddings: mx.array,
rotary_embeddings: mx.array,
) -> tuple[mx.array, mx.array]:
"""Forward pass that computes attention and populates the KV cache.
Args:
hidden_states: Full image hidden states [B, img_len, D]
encoder_hidden_states: Full text hidden states [B, text_len, D]
text_embeddings: Time + pooled text conditioning
rotary_embeddings: Full rotary embeddings for [text + full_image]
Returns:
Tuple of (encoder_hidden_states, hidden_states) after block processing
"""
text_seq_len = encoder_hidden_states.shape[1]
num_img_tokens = hidden_states.shape[1]
batch_size = hidden_states.shape[0]
# 1. Compute norms (same as PatchedJointTransformerBlock)
norm_hidden, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.block.norm1(
hidden_states=hidden_states,
text_embeddings=text_embeddings,
)
(
norm_encoder,
c_gate_msa,
c_shift_mlp,
c_scale_mlp,
c_gate_mlp,
) = self.block.norm1_context(
hidden_states=encoder_hidden_states,
text_embeddings=text_embeddings,
)
# 2. Compute Q, K, V for full image
img_query, img_key, img_value = AttentionUtils.process_qkv(
hidden_states=norm_hidden,
to_q=self.attn.to_q,
to_k=self.attn.to_k,
to_v=self.attn.to_v,
norm_q=self.attn.norm_q,
norm_k=self.attn.norm_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 3. Compute Q, K, V for text
txt_query, txt_key, txt_value = AttentionUtils.process_qkv(
hidden_states=norm_encoder,
to_q=self.attn.add_q_proj,
to_k=self.attn.add_k_proj,
to_v=self.attn.add_v_proj,
norm_q=self.attn.norm_added_q,
norm_k=self.attn.norm_added_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 4. Concatenate Q, K, V: [text, image]
query = mx.concatenate([txt_query, img_query], axis=2)
key = mx.concatenate([txt_key, img_key], axis=2)
value = mx.concatenate([txt_value, img_value], axis=2)
# 5. Apply RoPE to Q and K
query, key = AttentionUtils.apply_rope(
xq=query, xk=key, freqs_cis=rotary_embeddings
)
# 6. Store only IMAGE K/V in cache for async pipeline warmstart
# Text K/V is not cached - it's always computed fresh
self.kv_cache.update_image_patch(
patch_start=0,
patch_end=num_img_tokens,
key=key[:, :, text_seq_len:, :],
value=value[:, :, text_seq_len:, :],
)
# 7. Compute full attention (standard, not using stale cache)
attn_output = AttentionUtils.compute_attention(
query=query,
key=key,
value=value,
batch_size=batch_size,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 8. Extract outputs for text and image
context_attn_output = attn_output[:, :text_seq_len, :]
attn_output = attn_output[:, text_seq_len:, :]
# 9. Project outputs
attn_output = self.attn.to_out[0](attn_output)
context_attn_output = self.attn.to_add_out(context_attn_output)
# 10. Apply norm and feed forward (same as PatchedJointTransformerBlock)
hidden_states = JointTransformerBlock.apply_norm_and_feed_forward(
hidden_states=hidden_states,
attn_output=attn_output,
gate_mlp=gate_mlp,
gate_msa=gate_msa,
scale_mlp=scale_mlp,
shift_mlp=shift_mlp,
norm_layer=self.block.norm2,
ff_layer=self.block.ff,
)
encoder_hidden_states = JointTransformerBlock.apply_norm_and_feed_forward(
hidden_states=encoder_hidden_states,
attn_output=context_attn_output,
gate_mlp=c_gate_mlp,
gate_msa=c_gate_msa,
scale_mlp=c_scale_mlp,
shift_mlp=c_shift_mlp,
norm_layer=self.block.norm2_context,
ff_layer=self.block.ff_context,
)
return encoder_hidden_states, hidden_states
class CachingSingleTransformerBlock:
"""Single transformer block that captures K/V for cache during sync mode.
Implements the full block computation while storing K/V in the cache for
subsequent async timesteps to use as stale values. Computes K/V only once.
"""
def __init__(self, block: SingleTransformerBlock, kv_cache: ImagePatchKVCache):
"""Wrap an existing SingleTransformerBlock with a KV cache.
Args:
block: The original SingleTransformerBlock
kv_cache: KV cache to populate during forward pass
"""
self.block = block
self.kv_cache = kv_cache
self.attn = block.attn
self.num_heads = block.attn.num_heads
self.head_dim = block.attn.head_dimension
def __call__(
self,
hidden_states: mx.array,
text_embeddings: mx.array,
rotary_embeddings: mx.array,
text_seq_len: int,
) -> mx.array:
"""Forward pass that computes attention and populates the KV cache.
Args:
hidden_states: Full [text + image] hidden states [B, text_len + img_len, D]
text_embeddings: Time + pooled text conditioning
rotary_embeddings: Full rotary embeddings for [text + full_image]
text_seq_len: Length of text portion (needed to cache only image K/V)
Returns:
Output hidden states after block processing
"""
total_seq_len = hidden_states.shape[1]
num_img_tokens = total_seq_len - text_seq_len
batch_size = hidden_states.shape[0]
# 0. Establish residual connection
residual = hidden_states
# 1. Compute norm
norm_hidden, gate = self.block.norm(
hidden_states=hidden_states,
text_embeddings=text_embeddings,
)
# 2. Compute Q, K, V
query, key, value = AttentionUtils.process_qkv(
hidden_states=norm_hidden,
to_q=self.attn.to_q,
to_k=self.attn.to_k,
to_v=self.attn.to_v,
norm_q=self.attn.norm_q,
norm_k=self.attn.norm_k,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 3. Apply RoPE to Q and K
query, key = AttentionUtils.apply_rope(
xq=query, xk=key, freqs_cis=rotary_embeddings
)
# 4. Store only IMAGE K/V in cache for async pipeline warmstart
# Text K/V is not cached - it's always computed fresh
self.kv_cache.update_image_patch(
patch_start=0,
patch_end=num_img_tokens,
key=key[:, :, text_seq_len:, :],
value=value[:, :, text_seq_len:, :],
)
# 5. Compute full attention (standard, not using stale cache)
attn_output = AttentionUtils.compute_attention(
query=query,
key=key,
value=value,
batch_size=batch_size,
num_heads=self.num_heads,
head_dim=self.head_dim,
)
# 6. Apply feed forward and projection (same as PatchedSingleTransformerBlock)
hidden_states = self.block._apply_feed_forward_and_projection(
norm_hidden_states=norm_hidden,
attn_output=attn_output,
gate=gate,
)
return residual + hidden_states
+4 -4
View File
@@ -1,7 +1,7 @@
from exo.shared.types.worker.instances import BoundInstance
from exo.worker.engines.mflux.distributed_flux import DistributedFlux1
from exo.worker.engines.mflux.distributed_model import DistributedImageModel
def initialize_mflux(bound_instance: BoundInstance) -> DistributedFlux1:
"""Initialize DistributedFlux1 from a BoundInstance."""
return DistributedFlux1.from_bound_instance(bound_instance)
def initialize_mflux(bound_instance: BoundInstance) -> DistributedImageModel:
"""Initialize DistributedImageModel from a BoundInstance."""
return DistributedImageModel.from_bound_instance(bound_instance)