Signed-off-by: gcanlin <canlinguosdu@gmail.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
573 lines
18 KiB
Markdown
573 lines
18 KiB
Markdown
# Parallelism Patterns Reference
|
||
|
||
## Overview
|
||
|
||
vLLM-Omni supports multiple parallelism strategies for diffusion models. Each targets a different bottleneck:
|
||
|
||
| Strategy | Splits | Best For | Constraint |
|
||
|----------|--------|----------|------------|
|
||
| Tensor Parallel (TP) | Model layers across GPUs | Latency reduction, large models | Requires fast GPU interconnect, `num_heads % tp == 0` |
|
||
| Sequence Parallel (SP/USP) | Sequence tokens across GPUs | Long sequences (video, high-res) | Near-linear scaling |
|
||
| CFG Parallel | Positive/negative CFG branches | Models using classifier-free guidance | Exactly 2 GPUs |
|
||
| HSDP | Weight shards via FSDP2 | VRAM reduction | Cannot combine with TP |
|
||
| VAE Patch Parallel | VAE decode spatial tiles | Large VAE outputs | Auto-enables tiling |
|
||
|
||
**Recommended integration order**: TP → SP → CFG Parallel → HSDP
|
||
|
||
**Official design docs**:
|
||
- TP: https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/tensor_parallel
|
||
- SP: https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/sequence_parallel
|
||
- CFG: https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/cfg_parallel
|
||
- HSDP: https://docs.vllm.ai/projects/vllm-omni/en/latest/design/feature/hsdp
|
||
|
||
---
|
||
|
||
## Tensor Parallelism (TP)
|
||
|
||
Replace standard `nn.Linear` with vLLM's parallel linear layers. This is the most invasive change but provides direct VRAM savings and compute speedup.
|
||
|
||
### Layer replacement rules
|
||
|
||
| Pattern | vLLM Layer | When to Use |
|
||
|---------|-----------|-------------|
|
||
| Fan-out (first in FFN) | `ColumnParallelLinear` | Projection that splits output across ranks |
|
||
| Fan-in (second in FFN) | `RowParallelLinear` | Projection that gathers across ranks |
|
||
| QKV projection | `QKVParallelLinear` | Fused Q/K/V for self-attention |
|
||
| Single Q or K or V | `ColumnParallelLinear` | Separate projections (cross-attention) |
|
||
| Attention output | `RowParallelLinear` | Output projection after attention |
|
||
| Must not shard | `ReplicatedLinear` | Layers that must stay replicated |
|
||
|
||
### MLP Block (Up-Down Pattern)
|
||
|
||
```python
|
||
from vllm.model_executor.layers.linear import (
|
||
ColumnParallelLinear, RowParallelLinear,
|
||
)
|
||
|
||
class TPFeedForward(nn.Module):
|
||
def __init__(self, dim, ffn_dim):
|
||
super().__init__()
|
||
self.fc1 = ColumnParallelLinear(dim, ffn_dim, bias=False, return_bias=False)
|
||
self.fc2 = RowParallelLinear(
|
||
ffn_dim, dim, bias=False,
|
||
input_is_parallel=True, # Input already sharded from fc1
|
||
return_bias=False,
|
||
)
|
||
|
||
def forward(self, x):
|
||
x, _ = self.fc1(x)
|
||
x = torch.nn.functional.gelu(x)
|
||
x, _ = self.fc2(x)
|
||
return x
|
||
```
|
||
|
||
### Attention Block (QKV-Out Pattern)
|
||
|
||
```python
|
||
from vllm.model_executor.layers.linear import QKVParallelLinear, RowParallelLinear
|
||
from vllm_omni.diffusion.attention.layer import Attention
|
||
|
||
class TPSelfAttention(nn.Module):
|
||
def __init__(self, dim, num_heads, num_kv_heads=None):
|
||
super().__init__()
|
||
num_kv_heads = num_kv_heads or num_heads
|
||
self.head_dim = dim // num_heads
|
||
|
||
self.to_qkv = QKVParallelLinear(
|
||
hidden_size=dim,
|
||
head_size=self.head_dim,
|
||
total_num_heads=num_heads,
|
||
total_num_kv_heads=num_kv_heads,
|
||
bias=False,
|
||
return_bias=False,
|
||
)
|
||
self.to_out = RowParallelLinear(
|
||
dim, dim, bias=False,
|
||
input_is_parallel=True,
|
||
return_bias=False,
|
||
)
|
||
self.attn = Attention(
|
||
num_heads=self.to_qkv.num_heads, # Local heads per GPU
|
||
head_size=self.head_dim,
|
||
softmax_scale=1.0 / (self.head_dim ** 0.5),
|
||
causal=False,
|
||
num_kv_heads=self.to_qkv.num_kv_heads, # Local KV heads per GPU
|
||
role="self",
|
||
)
|
||
|
||
def forward(self, x):
|
||
qkv, _ = self.to_qkv(x)
|
||
q, k, v = qkv.split(
|
||
[self.to_qkv.num_heads * self.head_dim,
|
||
self.to_qkv.num_kv_heads * self.head_dim,
|
||
self.to_qkv.num_kv_heads * self.head_dim],
|
||
dim=-1,
|
||
)
|
||
B, S, _ = x.shape
|
||
q = q.view(B, S, self.to_qkv.num_heads, self.head_dim)
|
||
k = k.view(B, S, self.to_qkv.num_kv_heads, self.head_dim)
|
||
v = v.view(B, S, self.to_qkv.num_kv_heads, self.head_dim)
|
||
out = self.attn(q, k, v)
|
||
out = out.reshape(B, S, -1)
|
||
out, _ = self.to_out(out)
|
||
return out
|
||
```
|
||
|
||
### QKV Fusion in load_weights
|
||
|
||
When you fuse separate Q/K/V into `QKVParallelLinear`, map diffusers' separate weight names:
|
||
|
||
```python
|
||
stacked_params_mapping = [
|
||
("to_qkv", "to_q", "q"),
|
||
("to_qkv", "to_k", "k"),
|
||
("to_qkv", "to_v", "v"),
|
||
]
|
||
|
||
def load_weights(self, weights):
|
||
params = dict(self.named_parameters())
|
||
loaded = set()
|
||
for name, tensor in weights:
|
||
for fused_name, orig_name, shard_id in stacked_params_mapping:
|
||
if orig_name in name:
|
||
name = name.replace(orig_name, fused_name)
|
||
param = params[name]
|
||
param.weight_loader(param, tensor, shard_id)
|
||
loaded.add(name)
|
||
break
|
||
else:
|
||
if name in params:
|
||
param = params[name]
|
||
if hasattr(param, "weight_loader"):
|
||
param.weight_loader(param, tensor)
|
||
else:
|
||
default_weight_loader(param, tensor)
|
||
loaded.add(name)
|
||
return loaded
|
||
```
|
||
|
||
### RMSNorm with TP
|
||
|
||
When RMSNorm sits between TP-sharded dimensions, use `DistributedRMSNorm` — it computes global RMS via all-reduce across TP ranks. See the Wan2.2 implementation for the pattern.
|
||
|
||
### TP Constraints
|
||
|
||
- `num_heads % tp_size == 0`
|
||
- `num_kv_heads % tp_size == 0`
|
||
- Use `self.to_qkv.num_heads` (local per-GPU count), not total heads, for split sizes
|
||
|
||
### Testing TP
|
||
|
||
```bash
|
||
python text_to_image.py --model Your-org/your-model \
|
||
--tensor-parallel-size 2 --output "tp_test.png"
|
||
```
|
||
|
||
**Verify**: speedup, memory reduction proportional to TP size, quality matches single-GPU.
|
||
|
||
### Reference implementations
|
||
|
||
| Model | Path |
|
||
|-------|------|
|
||
| Z-Image | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` |
|
||
| FLUX | `vllm_omni/diffusion/models/flux/flux_transformer.py` |
|
||
| Qwen-Image | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` |
|
||
|
||
---
|
||
|
||
## Sequence Parallelism (SP / USP)
|
||
|
||
SP splits sequence tokens across GPUs using Ulysses (all-to-all) or Ring (P2P) communication. It is applied non-intrusively via the `_sp_plan` dict — no changes to `forward()` logic.
|
||
|
||
### Approach 1: Non-Intrusive `_sp_plan` (Recommended)
|
||
|
||
The framework automatically registers hooks to shard inputs and gather outputs at `nn.Module` boundaries.
|
||
|
||
#### Step 1: Identify module boundaries
|
||
|
||
Find where tensors need sharding/gathering:
|
||
|
||
```python
|
||
class MyTransformer(nn.Module):
|
||
def __init__(self):
|
||
self.patch_embed = PatchEmbed() # Before blocks
|
||
self.pos_embed = RoPE() # RoPE may need splitting
|
||
self.blocks = nn.ModuleList([...]) # Blocks process sharded x
|
||
self.norm_out = LayerNorm()
|
||
self.proj_out = Linear() # Gather after this
|
||
|
||
def forward(self, x):
|
||
x = self.patch_embed(x)
|
||
pos = self.pos_embed(x)
|
||
for block in self.blocks:
|
||
x = block(x, pos)
|
||
x = self.norm_out(x)
|
||
return self.proj_out(x)
|
||
```
|
||
|
||
#### Step 2: Handle inline operations
|
||
|
||
`_sp_plan` hooks only work at `nn.Module` boundaries. Inline ops like `torch.cat()` must be extracted into submodules:
|
||
|
||
```python
|
||
# BAD: Inline — hooks can't intercept
|
||
unified = torch.cat([x, cap_feats], dim=1)
|
||
|
||
# GOOD: Extract into submodule
|
||
class UnifiedPrepare(nn.Module):
|
||
def forward(self, x, cap_feats):
|
||
return torch.cat([x, cap_feats], dim=1)
|
||
|
||
self.unified_prepare = UnifiedPrepare()
|
||
unified = self.unified_prepare(x, cap_feats)
|
||
```
|
||
|
||
Common cases: `torch.cat()`, `pad_sequence()`, `tensor.reshape()`, complex preprocessing.
|
||
|
||
#### Step 3: Write `_sp_plan`
|
||
|
||
**Pattern 1: Shard at first block, gather at output** (most common)
|
||
|
||
```python
|
||
from vllm_omni.diffusion.distributed.sp_plan import (
|
||
SequenceParallelInput, SequenceParallelOutput,
|
||
)
|
||
|
||
class StandardTransformer(nn.Module):
|
||
_sp_plan = {
|
||
"blocks.0": {
|
||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||
},
|
||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||
}
|
||
```
|
||
|
||
**Pattern 2: Shard RoPE outputs separately**
|
||
|
||
```python
|
||
class TransformerWithRoPE(nn.Module):
|
||
_sp_plan = {
|
||
"rope": {
|
||
0: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||
1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True),
|
||
},
|
||
"blocks.0": {
|
||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||
},
|
||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||
}
|
||
```
|
||
|
||
**Pattern 3: Dual-stream (shard image, replicate text)**
|
||
|
||
```python
|
||
class DualStreamTransformer(nn.Module):
|
||
_sp_plan = {
|
||
"rope_preparer": {
|
||
2: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True),
|
||
3: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True),
|
||
},
|
||
"transformer_blocks.0": {
|
||
"hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3),
|
||
},
|
||
"proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||
}
|
||
```
|
||
|
||
### API Reference
|
||
|
||
**SequenceParallelInput**:
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `split_dim` | int | Dimension to split (usually 1 for sequence) |
|
||
| `expected_dims` | int/None | Expected tensor rank for validation |
|
||
| `split_output` | bool | `False`: shard input params; `True`: shard output tensors |
|
||
| `auto_pad` | bool | Auto-pad if sequence not divisible by world_size |
|
||
|
||
**SequenceParallelOutput**:
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `gather_dim` | int | Dimension to gather (usually 1 for sequence) |
|
||
| `expected_dims` | int/None | Expected tensor rank for validation |
|
||
|
||
**Module naming**:
|
||
|
||
| Key | Meaning |
|
||
|-----|---------|
|
||
| `"blocks.0"` | First element of ModuleList |
|
||
| `"blocks.*"` | All elements of ModuleList |
|
||
| `"rope"` | Named submodule |
|
||
|
||
**Dictionary value types**:
|
||
|
||
| Key type | split_output | Description |
|
||
|----------|-------------|-------------|
|
||
| `"param_name"` (str) | False | Shard input parameter by name |
|
||
| `0, 1, ...` (int) | True | Shard output tuple by index |
|
||
|
||
### Approach 2: Intrusive Modification (Complex Cases)
|
||
|
||
For dynamic sharding logic that can't be expressed via `_sp_plan`:
|
||
|
||
```python
|
||
from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather
|
||
|
||
def forward(self, hidden_states, ...):
|
||
if self.parallel_config.sequence_parallel_size > 1:
|
||
hidden_states = sp_shard(hidden_states, dim=1)
|
||
for block in self.blocks:
|
||
hidden_states = block(hidden_states)
|
||
if self.parallel_config.sequence_parallel_size > 1:
|
||
hidden_states = sp_gather(hidden_states, dim=1)
|
||
return hidden_states
|
||
```
|
||
|
||
Use intrusive modification as a last resort — `_sp_plan` is preferred for maintainability.
|
||
|
||
### UAA Mode (Experimental)
|
||
|
||
`ulysses_mode="advanced_uaa"` handles arbitrary sequence lengths and head counts that aren't divisible by `ulysses_degree`. Uses variable all-to-all split sizes and temporary head padding.
|
||
|
||
### Combining SP methods
|
||
|
||
Ulysses and Ring can be combined: `ulysses_degree × ring_degree = total SP GPUs`.
|
||
|
||
```python
|
||
DiffusionParallelConfig(ulysses_degree=2, ring_degree=2) # 4 GPUs total
|
||
```
|
||
|
||
### Testing SP
|
||
|
||
```bash
|
||
# Offline
|
||
python text_to_image.py --model Your-model --ulysses-degree 2
|
||
|
||
# Online serving
|
||
vllm serve Your-model --omni --usp 2
|
||
```
|
||
|
||
### Reference implementations
|
||
|
||
| Model | Path |
|
||
|-------|------|
|
||
| Qwen-Image | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` |
|
||
| Wan2.2 | `vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py` |
|
||
| Z-Image | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` |
|
||
|
||
---
|
||
|
||
## CFG Parallelism
|
||
|
||
Distributes positive/negative Classifier-Free Guidance branches across 2 GPUs.
|
||
|
||
### Implementation
|
||
|
||
Inherit `CFGParallelMixin` and implement `diffuse()`:
|
||
|
||
```python
|
||
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
|
||
|
||
class YourPipeline(nn.Module, CFGParallelMixin):
|
||
def diffuse(self, latents, timesteps, prompt_embeds, negative_embeds,
|
||
do_true_cfg, true_cfg_scale, **kwargs):
|
||
for i, t in enumerate(timesteps):
|
||
positive_kwargs = {
|
||
"hidden_states": latents,
|
||
"encoder_hidden_states": prompt_embeds,
|
||
"timestep": t,
|
||
}
|
||
negative_kwargs = {
|
||
"hidden_states": latents,
|
||
"encoder_hidden_states": negative_embeds,
|
||
"timestep": t,
|
||
} if do_true_cfg else None
|
||
|
||
noise_pred = self.predict_noise_maybe_with_cfg(
|
||
do_true_cfg=do_true_cfg,
|
||
true_cfg_scale=true_cfg_scale,
|
||
positive_kwargs=positive_kwargs,
|
||
negative_kwargs=negative_kwargs,
|
||
)
|
||
latents = self.scheduler_step_maybe_with_cfg(
|
||
noise_pred, t, latents, do_true_cfg
|
||
)
|
||
return latents
|
||
```
|
||
|
||
### Customization hooks
|
||
|
||
| Method | Override when |
|
||
|--------|-------------|
|
||
| `predict_noise()` | Non-standard transformer call (e.g., dual-transformer like Wan2.2) |
|
||
| `cfg_normalize_function()` | Custom normalization (e.g., LongCat with clamping) |
|
||
| `combine_cfg_noise()` | Multi-output models (e.g., video + audio: CFG on video, positive-only on audio) |
|
||
|
||
**Custom predict_noise** (Wan2.2 — selects active transformer):
|
||
|
||
```python
|
||
def predict_noise(self, current_model=None, **kwargs):
|
||
if current_model is None:
|
||
current_model = self.transformer
|
||
return current_model(**kwargs)[0]
|
||
```
|
||
|
||
**Custom combine_cfg_noise** (multi-output):
|
||
|
||
```python
|
||
def combine_cfg_noise(self, positive_pred, negative_pred, scale, normalize):
|
||
video_pos, audio_pos = positive_pred
|
||
video_neg, audio_neg = negative_pred
|
||
video_combined = super().combine_cfg_noise(video_pos, video_neg, scale, normalize)
|
||
return (video_combined, audio_pos)
|
||
```
|
||
|
||
### Composite scheduler for multi-output
|
||
|
||
When each output has its own schedule:
|
||
|
||
```python
|
||
class VideoAudioScheduler:
|
||
def __init__(self, video_scheduler, audio_scheduler):
|
||
self.video_scheduler = video_scheduler
|
||
self.audio_scheduler = audio_scheduler
|
||
|
||
def step(self, noise_pred, t, latents, return_dict=False, generator=None):
|
||
video_out = self.video_scheduler.step(
|
||
noise_pred[0], t[0], latents[0], return_dict=False, generator=generator
|
||
)[0]
|
||
audio_out = self.audio_scheduler.step(
|
||
noise_pred[1], t[1], latents[1], return_dict=False, generator=generator
|
||
)[0]
|
||
return ((video_out, audio_out),)
|
||
```
|
||
|
||
### Testing CFG Parallel
|
||
|
||
```bash
|
||
python text_to_image.py --model Your-model \
|
||
--cfg-parallel-size 2 --cfg-scale 4.0 \
|
||
--negative-prompt "ugly, unclear"
|
||
```
|
||
|
||
**Constraint**: `guidance_scale > 1.0` and negative prompt must be provided.
|
||
|
||
### Reference implementations
|
||
|
||
| Model | Path |
|
||
|-------|------|
|
||
| Qwen-Image | `vllm_omni/diffusion/models/qwen_image/cfg_parallel.py` |
|
||
| Wan2.2 | `vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py` |
|
||
| Mixin base | `vllm_omni/diffusion/distributed/cfg_parallel.py` |
|
||
|
||
---
|
||
|
||
## HSDP (Hybrid Sharded Data Parallel)
|
||
|
||
Shards model weights across GPUs using PyTorch FSDP2. Reduces per-GPU VRAM without changing computation.
|
||
|
||
### Implementation
|
||
|
||
Add `_hsdp_shard_conditions` to the transformer class:
|
||
|
||
```python
|
||
class YourTransformer(nn.Module):
|
||
@staticmethod
|
||
def _is_transformer_block(name: str, module) -> bool:
|
||
return "blocks" in name and name.split(".")[-1].isdigit()
|
||
|
||
_hsdp_shard_conditions = [_is_transformer_block]
|
||
```
|
||
|
||
For MoE models, add additional conditions:
|
||
|
||
```python
|
||
class MoETransformer(nn.Module):
|
||
@staticmethod
|
||
def _is_transformer_block(name, module):
|
||
return "blocks" in name and name.split(".")[-1].isdigit()
|
||
|
||
@staticmethod
|
||
def _is_moe_expert(name, module):
|
||
return "experts" in name and name.split(".")[-1].isdigit()
|
||
|
||
_hsdp_shard_conditions = [_is_transformer_block, _is_moe_expert]
|
||
```
|
||
|
||
A module is sharded if **any** condition returns `True`.
|
||
|
||
### Constraints
|
||
|
||
- Cannot combine with Tensor Parallelism
|
||
- For standalone HSDP (no other parallelism), `hsdp_shard_size` must be specified explicitly
|
||
- Can combine with SP: HSDP reduces memory while SP distributes sequence
|
||
|
||
### Testing HSDP
|
||
|
||
```python
|
||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||
|
||
parallel_config = DiffusionParallelConfig(use_hsdp=True, hsdp_shard_size=8)
|
||
omni = Omni(model="your-model", parallel_config=parallel_config)
|
||
```
|
||
|
||
Or CLI:
|
||
|
||
```bash
|
||
vllm serve Your-model --omni --use-hsdp
|
||
```
|
||
|
||
**Verify**: logs show "HSDP Inference: replicate_size=..., shard_size=..." and "Sharded N modules + root". Check VRAM reduction.
|
||
|
||
### Reference implementations
|
||
|
||
| Model | Path |
|
||
|-------|------|
|
||
| Wan2.2 | `vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py` |
|
||
| HSDP Core | `vllm_omni/diffusion/distributed/hsdp.py` |
|
||
|
||
---
|
||
|
||
## VAE Patch Parallelism
|
||
|
||
Shards VAE decode spatially across ranks using tiling:
|
||
|
||
```bash
|
||
python text_to_image.py --model Your-model --vae-patch-parallel-size 4
|
||
```
|
||
|
||
Auto-enables `--vae-use-tiling`. Uses `DistributedAutoencoderKLWan` or similar distributed VAE. Set `vae_patch_parallel_size` in `DiffusionParallelConfig`.
|
||
|
||
---
|
||
|
||
## Combining Parallelism Methods
|
||
|
||
Common multi-GPU recipes:
|
||
|
||
```bash
|
||
# 4 GPUs: CFG (2) × Ulysses (2)
|
||
python text_to_image.py --model Qwen/Qwen-Image \
|
||
--cfg-parallel-size 2 --ulysses-degree 2
|
||
|
||
# 8 GPUs: Ulysses (4) × Ring (2) + VAE patch (8)
|
||
python text_to_video.py --model Wan-AI/Wan2.2-T2V-A14B-Diffusers \
|
||
--ulysses-degree 4 --ring-degree 2 --vae-patch-parallel-size 8
|
||
|
||
# 2 GPUs: HSDP + Ulysses (cannot combine HSDP with TP)
|
||
vllm serve Your-model --omni --use-hsdp --usp 2
|
||
```
|
||
|
||
## Discovering Parallelism Support
|
||
|
||
Check which parallelism methods a model supports:
|
||
|
||
| Check | How |
|
||
|-------|-----|
|
||
| **Ulysses / Ring SP** | Transformer defines `_sp_plan`. Search: `grep -r '_sp_plan' vllm_omni/diffusion/models/` |
|
||
| **CFG Parallel** | Pipeline inherits `CFGParallelMixin`. Search: `grep -r 'CFGParallelMixin' vllm_omni/diffusion/models/` |
|
||
| **TP** | Uses `ColumnParallelLinear` / `QKVParallelLinear`. Search: `grep -r 'ParallelLinear\|QKVParallel' vllm_omni/diffusion/models/<model>/` |
|
||
| **HSDP** | Transformer defines `_hsdp_shard_conditions`. Search: `grep -r '_hsdp_shard_conditions' vllm_omni/diffusion/models/` |
|
||
|
||
The canonical per-model support table is in `docs/user_guide/diffusion/parallelism_acceleration.md`.
|