[diffusion] model: support minimax-h3 (#33275)
Co-authored-by: zhenaozhenfu <zhenaozhenfu@minimaxi.com> Co-authored-by: BBuf <1182563586@qq.com> Co-authored-by: andyluo7 <andy.luo@amd.com> Co-authored-by: Zijie Xia <zijie_xia@icloud.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: chao-xue <877184285@qq.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
zhenaozhenfu
BBuf
andyluo7
Zijie Xia
Claude Fable 5
chao-xue
Cursor
parent
0877a0e2f1
commit
70fe2e0dd5
@@ -6,6 +6,8 @@
|
||||
#include <sgl_kernel/vec.cuh>
|
||||
#include <sgl_kernel/warp.cuh>
|
||||
|
||||
#include <sgl_kernel/impl/norm.cuh>
|
||||
|
||||
#include <dlpack/dlpack.h>
|
||||
|
||||
#include <cstdint>
|
||||
@@ -42,7 +44,8 @@ constexpr uint32_t active_mask() {
|
||||
}
|
||||
}
|
||||
|
||||
SGL_DEVICE float load_cache_value(const float* ptr, int64_t idx) {
|
||||
template <typename CacheDType>
|
||||
SGL_DEVICE CacheDType load_cache_value(const CacheDType* ptr, int64_t idx) {
|
||||
#ifdef USE_ROCM
|
||||
return ptr[idx];
|
||||
#else
|
||||
@@ -50,7 +53,15 @@ SGL_DEVICE float load_cache_value(const float* ptr, int64_t idx) {
|
||||
#endif
|
||||
}
|
||||
|
||||
template <int64_t kHeadDim, int64_t kRopeDim, bool kIsNeox, bool kUsePDL, typename DType, typename IdType>
|
||||
template <
|
||||
int64_t kHeadDim,
|
||||
int64_t kRopeDim,
|
||||
bool kIsNeox,
|
||||
bool kUsePDL,
|
||||
typename DType,
|
||||
typename CacheDType,
|
||||
bool kRoundNormBeforeRope,
|
||||
typename IdType>
|
||||
__global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__ params) {
|
||||
using namespace device;
|
||||
|
||||
@@ -63,14 +74,17 @@ __global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__
|
||||
constexpr uint32_t kRotaryLanes = kRopeDim / kElemsPerThread;
|
||||
constexpr uint32_t kHalfRotaryLanes = kRotaryLanes / 2;
|
||||
constexpr uint32_t kActiveMask = active_mask<kRotaryLanes>();
|
||||
constexpr int64_t kCosSinStrideBytes = kRopeDim * sizeof(float);
|
||||
constexpr int64_t kCosSinStrideBytes = kRopeDim * sizeof(CacheDType);
|
||||
|
||||
static_assert(kElemsPerThread % 2 == 0, "Each lane must own an even number of elements");
|
||||
static_assert(kRopeDim > 0 && kRopeDim <= kHeadDim, "Invalid rope dimension");
|
||||
static_assert(kRopeDim % kElemsPerThread == 0, "rope_dim must align with per-lane vector width");
|
||||
static_assert(
|
||||
!kIsNeox || (kRotaryLanes >= 2 && ((kRotaryLanes & (kRotaryLanes - 1)) == 0)),
|
||||
"NeoX fused qknorm+rope requires rotary lane count to be a power of 2");
|
||||
!kIsNeox || (kRotaryLanes >= 2 && kRotaryLanes % 2 == 0),
|
||||
"NeoX fused qknorm+rope requires an even rotary lane count");
|
||||
static_assert(
|
||||
!kRoundNormBeforeRope || std::is_same_v<DType, CacheDType>,
|
||||
"Rounded QKNorm+RoPE requires cache and activation dtypes to match");
|
||||
|
||||
using Packed = packed_t<DType>;
|
||||
using Storage = AlignedVector<Packed, kVecSize>;
|
||||
@@ -98,6 +112,53 @@ __global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__
|
||||
auto input_vec = load_as<Storage>(input, lane_id);
|
||||
const auto weight_vec = load_as<Storage>(weight_ptr, lane_id);
|
||||
|
||||
if constexpr (kRoundNormBeforeRope) {
|
||||
auto output_vec = norm::apply_norm_warp<kHeadDim>(input_vec, weight_vec, eps);
|
||||
const auto pos = static_cast<int64_t>(static_cast<const IdType*>(positions)[token_id]);
|
||||
const auto cos_ptr = static_cast<const CacheDType*>(pointer::offset(cos_sin_cache_ptr, pos * kCosSinStrideBytes));
|
||||
const auto sin_ptr = cos_ptr + kRopeDim / 2;
|
||||
|
||||
if constexpr (kIsNeox) {
|
||||
if (lane_id < kRotaryLanes) {
|
||||
const auto partner_lane =
|
||||
lane_id < kHalfRotaryLanes ? lane_id + kHalfRotaryLanes : lane_id - kHalfRotaryLanes;
|
||||
#pragma unroll
|
||||
for (uint32_t j = 0; j < kVecSize; ++j) {
|
||||
auto partner_vec = output_vec[j];
|
||||
auto partner_bits = reinterpret_cast<const uint32_t&>(partner_vec);
|
||||
partner_bits = __shfl_sync(kActiveMask, partner_bits, partner_lane);
|
||||
reinterpret_cast<uint32_t&>(partner_vec) = partner_bits;
|
||||
auto& values = unpack(output_vec[j]);
|
||||
const auto& partner_values = unpack(partner_vec);
|
||||
#pragma unroll
|
||||
for (uint32_t i = 0; i < 2; ++i) {
|
||||
const auto half_idx = (lane_id % kHalfRotaryLanes) * kElemsPerThread + 2 * j + i;
|
||||
const auto cos = load_cache_value(cos_ptr, half_idx);
|
||||
const auto sin = load_cache_value(sin_ptr, half_idx);
|
||||
values[i] = lane_id < kHalfRotaryLanes ? values[i] * cos - partner_values[i] * sin
|
||||
: values[i] * cos + partner_values[i] * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lane_id < kRotaryLanes) {
|
||||
#pragma unroll
|
||||
for (uint32_t j = 0; j < kVecSize; ++j) {
|
||||
auto& values = unpack(output_vec[j]);
|
||||
const auto half_idx = lane_id * kElemsPerThread / 2 + j;
|
||||
const auto cos = load_cache_value(cos_ptr, half_idx);
|
||||
const auto sin = load_cache_value(sin_ptr, half_idx);
|
||||
const auto x = values[0];
|
||||
const auto y = values[1];
|
||||
values[0] = x * cos - y * sin;
|
||||
values[1] = y * cos + x * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
store_as<Storage>(const_cast<void*>(input), output_vec, lane_id);
|
||||
continue;
|
||||
}
|
||||
|
||||
float elems[kElemsPerThread];
|
||||
float sum_of_squares = 0.0f;
|
||||
|
||||
@@ -122,27 +183,28 @@ __global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__
|
||||
if constexpr (kIsNeox) {
|
||||
if (lane_id < kRotaryLanes) {
|
||||
const auto pos = static_cast<int64_t>(static_cast<const IdType*>(positions)[token_id]);
|
||||
const auto cos_ptr = static_cast<const float*>(pointer::offset(cos_sin_cache_ptr, pos * kCosSinStrideBytes));
|
||||
const auto cos_ptr =
|
||||
static_cast<const CacheDType*>(pointer::offset(cos_sin_cache_ptr, pos * kCosSinStrideBytes));
|
||||
const auto sin_ptr = cos_ptr + kRopeDim / 2;
|
||||
const auto partner_lane = lane_id < kHalfRotaryLanes ? lane_id + kHalfRotaryLanes : lane_id - kHalfRotaryLanes;
|
||||
|
||||
#pragma unroll
|
||||
for (uint32_t i = 0; i < kElemsPerThread; ++i) {
|
||||
float swapped = __shfl_xor_sync(kActiveMask, elems[i], kHalfRotaryLanes);
|
||||
float swapped = __shfl_sync(kActiveMask, elems[i], partner_lane);
|
||||
if (lane_id < kHalfRotaryLanes) {
|
||||
swapped = -swapped;
|
||||
}
|
||||
int dim_idx = static_cast<int>(lane_id * kElemsPerThread + i);
|
||||
dim_idx = (dim_idx * 2) % kRopeDim;
|
||||
const int half_idx = dim_idx / 2;
|
||||
const float cos = load_cache_value(cos_ptr, half_idx);
|
||||
const float sin = load_cache_value(sin_ptr, half_idx);
|
||||
const auto half_idx = (lane_id % kHalfRotaryLanes) * kElemsPerThread + i;
|
||||
const float cos = cast<fp32_t>(load_cache_value(cos_ptr, half_idx));
|
||||
const float sin = cast<fp32_t>(load_cache_value(sin_ptr, half_idx));
|
||||
elems[i] = elems[i] * cos + swapped * sin;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lane_id < kRotaryLanes) {
|
||||
const auto pos = static_cast<int64_t>(static_cast<const IdType*>(positions)[token_id]);
|
||||
const auto cos_ptr = static_cast<const float*>(pointer::offset(cos_sin_cache_ptr, pos * kCosSinStrideBytes));
|
||||
const auto cos_ptr =
|
||||
static_cast<const CacheDType*>(pointer::offset(cos_sin_cache_ptr, pos * kCosSinStrideBytes));
|
||||
const auto sin_ptr = cos_ptr + kRopeDim / 2;
|
||||
|
||||
#pragma unroll
|
||||
@@ -150,8 +212,8 @@ __global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__
|
||||
const float x = elems[i];
|
||||
const float y = elems[i + 1];
|
||||
const int half_idx = static_cast<int>(lane_id * kElemsPerThread + i) / 2;
|
||||
const float cos = load_cache_value(cos_ptr, half_idx);
|
||||
const float sin = load_cache_value(sin_ptr, half_idx);
|
||||
const float cos = cast<fp32_t>(load_cache_value(cos_ptr, half_idx));
|
||||
const float sin = cast<fp32_t>(load_cache_value(sin_ptr, half_idx));
|
||||
elems[i] = x * cos - y * sin;
|
||||
elems[i + 1] = y * cos + x * sin;
|
||||
}
|
||||
@@ -168,11 +230,19 @@ __global__ void fused_qknorm_rope_warp(const QKNormRopeParams __grid_constant__
|
||||
PDLTriggerSecondary<kUsePDL>();
|
||||
}
|
||||
|
||||
template <int64_t kHeadDim, int64_t kRopeDim, bool kIsNeox, bool kUsePDL, typename DType>
|
||||
template <
|
||||
int64_t kHeadDim,
|
||||
int64_t kRopeDim,
|
||||
bool kIsNeox,
|
||||
bool kUsePDL,
|
||||
typename DType,
|
||||
typename CacheDType,
|
||||
bool kRoundNormBeforeRope>
|
||||
struct QKNormRopeKernel {
|
||||
static_assert(kHeadDim <= 256, "Only head_dim <= 256 is supported");
|
||||
template <typename IdType>
|
||||
static constexpr auto kernel = fused_qknorm_rope_warp<kHeadDim, kRopeDim, kIsNeox, kUsePDL, DType, IdType>;
|
||||
static constexpr auto kernel =
|
||||
fused_qknorm_rope_warp<kHeadDim, kRopeDim, kIsNeox, kUsePDL, DType, CacheDType, kRoundNormBeforeRope, IdType>;
|
||||
|
||||
static void
|
||||
run(const tvm::ffi::TensorView q,
|
||||
@@ -201,7 +271,7 @@ struct QKNormRopeKernel {
|
||||
TensorMatcher({N, Q, D}).with_strides({Dq, Dd, 1}).with_dtype<DType>().with_device(device).verify(q);
|
||||
TensorMatcher({N, K, D}).with_strides({Dk, Dd, 1}).with_dtype<DType>().with_device(device).verify(k);
|
||||
TensorMatcher({D}).with_dtype<DType>().with_device(device).verify(q_weight).verify(k_weight);
|
||||
TensorMatcher({-1, R}).with_dtype<float>().with_device(device).verify(cos_sin_cache);
|
||||
TensorMatcher({-1, R}).with_dtype<CacheDType>().with_device(device).verify(cos_sin_cache);
|
||||
TensorMatcher({N}).with_dtype<int32_t, int64_t>(id_type).with_device(device).verify(positions);
|
||||
|
||||
const auto num_tokens = static_cast<uint32_t>(N.unwrap());
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// CUDA fast path for the Ulysses sequence-parallel output head merge.
|
||||
//
|
||||
// usp_merge_heads:
|
||||
// x [W, S, B, h_local, D] (contiguous, the output all-to-all result)
|
||||
// -> out [B, S, W, h_local, D] (contiguous)
|
||||
// Replaces `x.permute(2, 1, 0, 3, 4).contiguous()` on the head_dim=2
|
||||
// output path of `_usp_output_all_to_all`.
|
||||
//
|
||||
// A pure copy (no arithmetic), so it is bit-exact with the eager permute by
|
||||
// construction. It exists because ATen's generic permute-copy reaches well
|
||||
// under half of HBM bandwidth on the packed-DiT shapes, while a single pass
|
||||
// with coalesced vectorized stores runs near roofline.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <sgl_kernel/tensor.h> // For host dtype helpers and TensorView metadata
|
||||
#include <sgl_kernel/utils.h> // For RuntimeCheck and div_ceil
|
||||
|
||||
#include <sgl_kernel/type.cuh> // For CUDA dtype aliases
|
||||
#include <sgl_kernel/utils.cuh> // For LaunchKernel
|
||||
#include <sgl_kernel/vec.cuh> // For device::AlignedVector
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace sglang_usp_relayout {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kBlockSize = 256;
|
||||
constexpr int64_t kMaxGrid = 65535;
|
||||
|
||||
inline const char* data_ptr(const tvm::ffi::TensorView& t) {
|
||||
return static_cast<const char*>(t.data_ptr()) + t.byte_offset();
|
||||
}
|
||||
|
||||
inline char* mutable_data_ptr(const tvm::ffi::TensorView& t) {
|
||||
return static_cast<char*>(t.data_ptr()) + t.byte_offset();
|
||||
}
|
||||
|
||||
inline bool aligned16(const void* p) {
|
||||
return (reinterpret_cast<uintptr_t>(p) & 0xF) == 0;
|
||||
}
|
||||
|
||||
inline int64_t numel(const tvm::ffi::TensorView& t) {
|
||||
int64_t n = 1;
|
||||
for (int i = 0; i < t.ndim(); ++i) {
|
||||
n *= t.size(i);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
inline int64_t grid_for(int64_t total) {
|
||||
int64_t grid = host::div_ceil(total, static_cast<int64_t>(kBlockSize));
|
||||
if (grid < 1) {
|
||||
grid = 1;
|
||||
}
|
||||
if (grid > kMaxGrid) {
|
||||
grid = kMaxGrid;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
inline bool is_dense_contiguous(const tvm::ffi::TensorView& t) {
|
||||
int64_t expected = 1;
|
||||
for (int i = t.ndim() - 1; i >= 0; --i) {
|
||||
if (t.size(i) == 1) {
|
||||
continue;
|
||||
}
|
||||
if (t.stride(i) != expected) {
|
||||
return false;
|
||||
}
|
||||
expected *= t.size(i);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
inline void check_dtype(const tvm::ffi::TensorView& t) {
|
||||
host::RuntimeCheck(host::is_type<T>(t.dtype()), "unexpected dtype for usp_merge_heads tensor");
|
||||
}
|
||||
|
||||
// out[b, s, w, h, c] = x[w, s, b, h, c]
|
||||
template <typename T, int kVec>
|
||||
__global__ void usp_merge_heads_vec_kernel(
|
||||
T* __restrict__ out,
|
||||
const T* __restrict__ x,
|
||||
int64_t n_vec,
|
||||
int64_t d_vec, // D / kVec
|
||||
int64_t h_local,
|
||||
int64_t batch,
|
||||
int64_t seq,
|
||||
int64_t world) {
|
||||
const int64_t stride = static_cast<int64_t>(gridDim.x) * blockDim.x;
|
||||
for (int64_t i = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x; i < n_vec; i += stride) {
|
||||
int64_t rest = i;
|
||||
const int64_t c_vec = rest % d_vec;
|
||||
rest /= d_vec;
|
||||
const int64_t h = rest % h_local;
|
||||
rest /= h_local;
|
||||
const int64_t w = rest % world;
|
||||
rest /= world;
|
||||
const int64_t s = rest % seq;
|
||||
const int64_t b = rest / seq;
|
||||
|
||||
const int64_t src_vec = ((((w * seq + s) * batch + b) * h_local) + h) * d_vec + c_vec;
|
||||
device::AlignedVector<T, kVec> val;
|
||||
val.load(x, src_vec);
|
||||
val.store(out, i);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
__global__ void usp_merge_heads_scalar_kernel(
|
||||
T* __restrict__ out,
|
||||
const T* __restrict__ x,
|
||||
int64_t total,
|
||||
int64_t head_dim,
|
||||
int64_t h_local,
|
||||
int64_t batch,
|
||||
int64_t seq,
|
||||
int64_t world) {
|
||||
const int64_t stride = static_cast<int64_t>(gridDim.x) * blockDim.x;
|
||||
for (int64_t i = static_cast<int64_t>(blockIdx.x) * blockDim.x + threadIdx.x; i < total; i += stride) {
|
||||
int64_t rest = i;
|
||||
const int64_t c = rest % head_dim;
|
||||
rest /= head_dim;
|
||||
const int64_t h = rest % h_local;
|
||||
rest /= h_local;
|
||||
const int64_t w = rest % world;
|
||||
rest /= world;
|
||||
const int64_t s = rest % seq;
|
||||
const int64_t b = rest / seq;
|
||||
|
||||
out[i] = x[((((w * seq + s) * batch + b) * h_local) + h) * head_dim + c];
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
template <typename T>
|
||||
struct UspMergeHeadsKernel {
|
||||
static void run(tvm::ffi::TensorView out, tvm::ffi::TensorView x) {
|
||||
check_dtype<T>(out);
|
||||
check_dtype<T>(x);
|
||||
host::RuntimeCheck(x.ndim() == 5, "x must be [W, S, B, h_local, D]");
|
||||
host::RuntimeCheck(out.ndim() == 5, "out must be [B, S, W, h_local, D]");
|
||||
for (auto* t : {&x, &out}) {
|
||||
host::RuntimeCheck(t->device().device_type == kDLCUDA, "usp_merge_heads tensors must be CUDA");
|
||||
host::RuntimeCheck(is_dense_contiguous(*t), "usp_merge_heads tensors must be contiguous");
|
||||
}
|
||||
const int64_t world = x.size(0);
|
||||
const int64_t seq = x.size(1);
|
||||
const int64_t batch = x.size(2);
|
||||
const int64_t h_local = x.size(3);
|
||||
const int64_t head_dim = x.size(4);
|
||||
host::RuntimeCheck(
|
||||
out.size(0) == batch && out.size(1) == seq && out.size(2) == world && out.size(3) == h_local &&
|
||||
out.size(4) == head_dim,
|
||||
"out must be the [B, S, W, h_local, D] permutation of x");
|
||||
|
||||
const int64_t total = numel(x);
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
T* out_ptr = reinterpret_cast<T*>(mutable_data_ptr(out));
|
||||
const T* x_ptr = reinterpret_cast<const T*>(data_ptr(x));
|
||||
|
||||
constexpr int kVec = 16 / sizeof(T);
|
||||
const bool vec_ok = (head_dim % kVec == 0) && aligned16(out_ptr) && aligned16(x_ptr);
|
||||
if (vec_ok) {
|
||||
const int64_t n_vec = total / kVec;
|
||||
host::LaunchKernel(static_cast<uint32_t>(grid_for(n_vec)), kBlockSize, out.device())(
|
||||
usp_merge_heads_vec_kernel<T, kVec>, out_ptr, x_ptr, n_vec, head_dim / kVec, h_local, batch, seq, world);
|
||||
} else {
|
||||
host::LaunchKernel(static_cast<uint32_t>(grid_for(total)), kBlockSize, out.device())(
|
||||
usp_merge_heads_scalar_kernel<T>, out_ptr, x_ptr, total, head_dim, h_local, batch, seq, world);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace sglang_usp_relayout
|
||||
@@ -55,7 +55,13 @@ struct ActivationParams {
|
||||
uint32_t expert_step;
|
||||
};
|
||||
|
||||
template <typename T, ActivationKind kAct, bool kUsePDL, bool kFilterExpert>
|
||||
template <
|
||||
typename T,
|
||||
ActivationKind kAct,
|
||||
bool kUsePDL,
|
||||
bool kFilterExpert,
|
||||
bool kRoundActivation = false,
|
||||
bool kReuseInput = false>
|
||||
__global__ void act_and_mul_kernel(const __grid_constant__ ActivationParams params) {
|
||||
using namespace device;
|
||||
constexpr auto kVecSize = kMaxVecBytes / sizeof(T);
|
||||
@@ -70,7 +76,7 @@ __global__ void act_and_mul_kernel(const __grid_constant__ ActivationParams para
|
||||
}
|
||||
const auto offset = tid % num_vecs;
|
||||
const auto input_offset = token_id * (num_vecs * 2) + offset;
|
||||
const auto output_offset = tid;
|
||||
const auto output_offset = kReuseInput ? input_offset : tid;
|
||||
PDLWaitPrimary<kUsePDL>();
|
||||
const auto gate = device::load_as<vec_t>(params.input, input_offset);
|
||||
const auto up = device::load_as<vec_t>(params.input, input_offset + num_vecs);
|
||||
@@ -79,9 +85,18 @@ __global__ void act_and_mul_kernel(const __grid_constant__ ActivationParams para
|
||||
for (int i = 0; i < kVecSize; ++i) {
|
||||
const float gate_f32 = device::cast<fp32_t>(gate[i]);
|
||||
const float up_f32 = device::cast<fp32_t>(up[i]);
|
||||
out[i] = device::cast<T>(apply_activation_f32<kAct>(gate_f32) * up_f32);
|
||||
if constexpr (kRoundActivation) {
|
||||
const T activated = device::cast<T>(apply_activation_f32<kAct>(gate_f32));
|
||||
out[i] = device::cast<T>(device::cast<fp32_t>(activated) * up_f32);
|
||||
} else {
|
||||
out[i] = device::cast<T>(apply_activation_f32<kAct>(gate_f32) * up_f32);
|
||||
}
|
||||
}
|
||||
if constexpr (kReuseInput) {
|
||||
device::store_as<vec_t>(const_cast<void*>(params.input), out, output_offset);
|
||||
} else {
|
||||
device::store_as<vec_t>(params.out, out, output_offset);
|
||||
}
|
||||
device::store_as<vec_t>(params.out, out, output_offset);
|
||||
PDLTriggerSecondary<kUsePDL>();
|
||||
}
|
||||
|
||||
@@ -117,26 +132,28 @@ struct ActivationKernel {
|
||||
using kernel_fn_t = decltype(&act_and_mul_kernel<T, ActivationKind::kSiLU, kUsePDL, false>);
|
||||
using unary_kernel_fn_t = decltype(&act_kernel<T, ActivationKind::kReLU2, kUsePDL>);
|
||||
|
||||
template <ActivationKind kAct, bool kFilterExpert>
|
||||
static constexpr kernel_fn_t activation_kernel = act_and_mul_kernel<T, kAct, kUsePDL, kFilterExpert>;
|
||||
template <ActivationKind kAct, bool kFilterExpert, bool kRoundActivation = false, bool kReuseInput = false>
|
||||
static constexpr kernel_fn_t activation_kernel =
|
||||
act_and_mul_kernel<T, kAct, kUsePDL, kFilterExpert, kRoundActivation, kReuseInput>;
|
||||
|
||||
static_assert(device::kMaxVecBytes % sizeof(T) == 0, "unsupported data type");
|
||||
|
||||
template <bool kFilterExpert>
|
||||
template <bool kFilterExpert, bool kRoundActivation = false, bool kReuseInput = false>
|
||||
static kernel_fn_t select_kernel(const std::string& type) {
|
||||
using namespace host;
|
||||
if (type == "silu") {
|
||||
return activation_kernel<ActivationKind::kSiLU, kFilterExpert>;
|
||||
return activation_kernel<ActivationKind::kSiLU, kFilterExpert, kRoundActivation, kReuseInput>;
|
||||
} else if (type == "gelu") {
|
||||
return activation_kernel<ActivationKind::kGELU, kFilterExpert>;
|
||||
return activation_kernel<ActivationKind::kGELU, kFilterExpert, kRoundActivation, kReuseInput>;
|
||||
} else if (type == "gelu_tanh") {
|
||||
return activation_kernel<ActivationKind::kGELUTanh, kFilterExpert>;
|
||||
return activation_kernel<ActivationKind::kGELUTanh, kFilterExpert, kRoundActivation, kReuseInput>;
|
||||
} else {
|
||||
Panic("unsupported activation type: ", type);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
template <bool kRoundActivation = false, bool kReuseInput = false>
|
||||
static void launch(
|
||||
const tvm::ffi::TensorView& input,
|
||||
const tvm::ffi::TensorView& out,
|
||||
@@ -151,10 +168,11 @@ struct ActivationKernel {
|
||||
auto device_ = SymbolicDevice{};
|
||||
device_.set_options<kDLCUDA>();
|
||||
|
||||
TensorMatcher({N, D_out}) //
|
||||
.with_dtype<T>()
|
||||
.with_device(device_)
|
||||
.verify(out);
|
||||
if constexpr (kReuseInput) {
|
||||
TensorMatcher({N, D_out}).with_strides({D_in, 1}).with_dtype<T>().with_device(device_).verify(out);
|
||||
} else {
|
||||
TensorMatcher({N, D_out}).with_dtype<T>().with_device(device_).verify(out);
|
||||
}
|
||||
TensorMatcher({N, D_in}) //
|
||||
.with_dtype<T>()
|
||||
.with_device(device_)
|
||||
@@ -166,13 +184,16 @@ struct ActivationKernel {
|
||||
if (num_tokens == 0) return;
|
||||
RuntimeCheck(hidden_size * 2 == D_in.unwrap(), "invalid activation dimension");
|
||||
RuntimeCheck(hidden_size % kVecSize == 0, "hidden size must be divisible by vector size");
|
||||
if constexpr (kReuseInput) {
|
||||
RuntimeCheck(input.data_ptr() == out.data_ptr(), "in-place activation output must alias input");
|
||||
}
|
||||
// only get once to avoid overhead
|
||||
const auto num_total_items = num_tokens * (hidden_size / kVecSize);
|
||||
RuntimeCheck(num_total_items <= std::numeric_limits<uint32_t>::max(), "too many items for 32-bit indexing");
|
||||
const auto num_blocks = div_ceil(static_cast<uint32_t>(num_total_items), kBlockSize);
|
||||
const auto params = ActivationParams{
|
||||
.input = input.data_ptr(),
|
||||
.out = out.data_ptr(),
|
||||
.out = kReuseInput ? nullptr : out.data_ptr(),
|
||||
.hidden_dim = hidden_size,
|
||||
.num_tokens = num_tokens,
|
||||
.expert_ids = expert_ids,
|
||||
@@ -180,10 +201,10 @@ struct ActivationKernel {
|
||||
};
|
||||
if (expert_ids != nullptr) {
|
||||
RuntimeCheck(expert_step > 0, "expert_step must be positive");
|
||||
const auto kernel = select_kernel<true>(type);
|
||||
const auto kernel = select_kernel<true, kRoundActivation, kReuseInput>(type);
|
||||
LaunchKernel(num_blocks, kBlockSize, device).enable_pdl(kUsePDL)(kernel, params);
|
||||
} else {
|
||||
const auto kernel = select_kernel<false>(type);
|
||||
const auto kernel = select_kernel<false, kRoundActivation, kReuseInput>(type);
|
||||
LaunchKernel(num_blocks, kBlockSize, device).enable_pdl(kUsePDL)(kernel, params);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +213,16 @@ struct ActivationKernel {
|
||||
launch(input, out, type, /*expert_ids=*/nullptr, /*expert_step=*/1);
|
||||
}
|
||||
|
||||
static void
|
||||
run_activation_with_rounding(const tvm::ffi::TensorView input, const tvm::ffi::TensorView out, std::string type) {
|
||||
launch<true>(input, out, type, /*expert_ids=*/nullptr, /*expert_step=*/1);
|
||||
}
|
||||
|
||||
static void run_activation_with_rounding_input_inplace(
|
||||
const tvm::ffi::TensorView input, const tvm::ffi::TensorView out, std::string type) {
|
||||
launch<true, true>(input, out, type, /*expert_ids=*/nullptr, /*expert_step=*/1);
|
||||
}
|
||||
|
||||
static void run_activation_filtered(
|
||||
const tvm::ffi::TensorView input,
|
||||
const tvm::ffi::TensorView out,
|
||||
|
||||
@@ -29,15 +29,26 @@ def _fast_math_flags() -> list[str]:
|
||||
|
||||
|
||||
@cache_once
|
||||
def activation_module(dtype: torch.dtype) -> Module:
|
||||
def activation_module(dtype: torch.dtype, *, fast_math: bool = True) -> Module:
|
||||
fast_math_flags = _fast_math_flags()
|
||||
if not fast_math and not fast_math_flags:
|
||||
return activation_module(dtype)
|
||||
args = make_cpp_args(dtype, is_arch_support_pdl())
|
||||
return load_jit(
|
||||
"activation",
|
||||
"activation" if fast_math else "rounded_activation",
|
||||
*args,
|
||||
cuda_files=["elementwise/activation.cuh"],
|
||||
extra_cuda_cflags=_fast_math_flags(),
|
||||
extra_cuda_cflags=fast_math_flags if fast_math else [],
|
||||
cuda_wrappers=[
|
||||
("run_activation", f"ActivationKernel<{args}>::run_activation"),
|
||||
(
|
||||
"run_activation_with_rounding",
|
||||
f"ActivationKernel<{args}>::run_activation_with_rounding",
|
||||
),
|
||||
(
|
||||
"run_activation_with_rounding_input_inplace",
|
||||
f"ActivationKernel<{args}>::run_activation_with_rounding_input_inplace",
|
||||
),
|
||||
(
|
||||
"run_activation_filtered",
|
||||
f"ActivationKernel<{args}>::run_activation_filtered",
|
||||
@@ -65,6 +76,28 @@ def _run_activation_inplace(
|
||||
module.run_activation(input_2d, out_2d, op_name)
|
||||
|
||||
|
||||
@register_custom_op(mutates_args=["out"])
|
||||
def _run_activation_with_rounding_inplace(
|
||||
op_name: str, input: torch.Tensor, out: torch.Tensor
|
||||
) -> None:
|
||||
hidden_size = input.shape[-1] // 2
|
||||
# Fast-math changes FP16 SiLU at eager rounding boundaries on SM90.
|
||||
module = activation_module(input.dtype, fast_math=False)
|
||||
input_2d = input.view(-1, hidden_size * 2)
|
||||
out_2d = out.view(-1, hidden_size)
|
||||
module.run_activation_with_rounding(input_2d, out_2d, op_name)
|
||||
|
||||
|
||||
@register_custom_op(mutates_args=["input"])
|
||||
def _run_silu_and_mul_with_rounding_inplace(input: torch.Tensor) -> None:
|
||||
hidden_size = input.shape[-1] // 2
|
||||
module = activation_module(input.dtype, fast_math=False)
|
||||
input_2d = input.view(-1, hidden_size * 2)
|
||||
module.run_activation_with_rounding_input_inplace(
|
||||
input_2d, input_2d[:, :hidden_size], "silu"
|
||||
)
|
||||
|
||||
|
||||
@register_custom_op(mutates_args=["out"])
|
||||
def _run_activation_filtered_inplace(
|
||||
op_name: str,
|
||||
@@ -150,6 +183,23 @@ def silu_and_mul(
|
||||
return run_activation("silu", input, out, expert_ids, expert_step)
|
||||
|
||||
|
||||
def silu_and_mul_with_activation_rounding(
|
||||
input: torch.Tensor,
|
||||
out: Optional[torch.Tensor] = None,
|
||||
) -> torch.Tensor:
|
||||
hidden_size = input.shape[-1] // 2
|
||||
if out is None:
|
||||
out = input.new_empty(*input.shape[:-1], hidden_size)
|
||||
_run_activation_with_rounding_inplace("silu", input, out)
|
||||
return out
|
||||
|
||||
|
||||
def silu_and_mul_with_activation_rounding_(input: torch.Tensor) -> torch.Tensor:
|
||||
hidden_size = input.shape[-1] // 2
|
||||
_run_silu_and_mul_with_rounding_inplace(input)
|
||||
return input[..., :hidden_size]
|
||||
|
||||
|
||||
def gelu_and_mul(
|
||||
input: torch.Tensor,
|
||||
out: Optional[torch.Tensor] = None,
|
||||
|
||||
@@ -26,8 +26,18 @@ def _jit_qknorm_rope_module(
|
||||
rope_dim: int,
|
||||
is_neox: bool,
|
||||
dtype: torch.dtype,
|
||||
cache_dtype: torch.dtype,
|
||||
round_norm_before_rope: bool,
|
||||
) -> Module:
|
||||
args = make_cpp_args(head_dim, rope_dim, is_neox, is_arch_support_pdl(), dtype)
|
||||
args = make_cpp_args(
|
||||
head_dim,
|
||||
rope_dim,
|
||||
is_neox,
|
||||
is_arch_support_pdl(),
|
||||
dtype,
|
||||
cache_dtype,
|
||||
round_norm_before_rope,
|
||||
)
|
||||
return load_jit(
|
||||
"qknorm_rope",
|
||||
*args,
|
||||
@@ -43,6 +53,8 @@ def can_use_fused_inplace_qknorm_rope(
|
||||
rope_dim: int,
|
||||
is_neox: bool,
|
||||
dtype: torch.dtype,
|
||||
cache_dtype: torch.dtype = torch.float32,
|
||||
round_norm_before_rope: bool = False,
|
||||
) -> bool:
|
||||
if head_dim not in (64, 128, 256):
|
||||
logger.warning(f"Unsupported head_dim={head_dim} for JIT fused QKNorm+RoPE")
|
||||
@@ -62,15 +74,29 @@ def can_use_fused_inplace_qknorm_rope(
|
||||
return False
|
||||
if is_neox:
|
||||
rotary_lanes = rope_dim // elems_per_thread
|
||||
if rotary_lanes < 2 or rotary_lanes & (rotary_lanes - 1):
|
||||
if rotary_lanes < 2 or rotary_lanes % 2:
|
||||
logger.warning(
|
||||
"rope_dim=%s yields invalid rotary_lanes=%s for neox fused QKNorm+RoPE; rotary lane count must be a power of 2",
|
||||
"rope_dim=%s yields invalid rotary_lanes=%s for neox fused QKNorm+RoPE; rotary lane count must be even",
|
||||
rope_dim,
|
||||
rotary_lanes,
|
||||
)
|
||||
return False
|
||||
if round_norm_before_rope and cache_dtype != dtype:
|
||||
logger.warning(
|
||||
"Exact fused QKNorm+RoPE requires cache dtype %s to match activation dtype %s",
|
||||
cache_dtype,
|
||||
dtype,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
_jit_qknorm_rope_module(head_dim, rope_dim, is_neox, dtype)
|
||||
_jit_qknorm_rope_module(
|
||||
head_dim,
|
||||
rope_dim,
|
||||
is_neox,
|
||||
dtype,
|
||||
cache_dtype,
|
||||
round_norm_before_rope,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load JIT fused QKNorm+RoPE kernel: {e}")
|
||||
@@ -90,8 +116,16 @@ def fused_inplace_qknorm_rope(
|
||||
eps: float = 1e-6,
|
||||
head_dim: int = 0,
|
||||
rope_dim: int = 0,
|
||||
round_norm_before_rope: bool = False,
|
||||
) -> None:
|
||||
head_dim = head_dim or q.size(-1)
|
||||
rope_dim = rope_dim or cos_sin_cache.size(-1)
|
||||
module = _jit_qknorm_rope_module(head_dim, rope_dim, is_neox, q.dtype)
|
||||
module = _jit_qknorm_rope_module(
|
||||
head_dim,
|
||||
rope_dim,
|
||||
is_neox,
|
||||
q.dtype,
|
||||
cos_sin_cache.dtype,
|
||||
round_norm_before_rope,
|
||||
)
|
||||
module.qknorm_rope(q, k, q_weight, k_weight, cos_sin_cache, positions, eps)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import torch
|
||||
import triton
|
||||
import triton.language as tl
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _round_bf16_to_fp32(value):
|
||||
# force the eager BF16 kernel boundary so Triton cannot contract the next add
|
||||
bits = value.to(tl.int32, bitcast=True)
|
||||
rounding_bias = 0x7FFF + ((bits >> 16) & 1)
|
||||
rounded_bits = (bits + rounding_bias) & -65536
|
||||
return rounded_bits.to(tl.float32, bitcast=True)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _indexed_scale_shift_bf16_kernel(
|
||||
output_ptr,
|
||||
x_ptr,
|
||||
shift_ptr,
|
||||
scale_ptr,
|
||||
indices_ptr,
|
||||
hidden_size,
|
||||
stride_x_row,
|
||||
stride_shift_row,
|
||||
stride_scale_row,
|
||||
stride_indices,
|
||||
BLOCK_N: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0)
|
||||
columns = tl.arange(0, BLOCK_N)
|
||||
mask = columns < hidden_size
|
||||
index = tl.load(indices_ptr + row * stride_indices)
|
||||
|
||||
x = tl.load(x_ptr + row * stride_x_row + columns, mask=mask, other=0.0).to(
|
||||
tl.float32
|
||||
)
|
||||
shift = tl.load(
|
||||
shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0
|
||||
).to(tl.float32)
|
||||
scale = tl.load(
|
||||
scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0
|
||||
).to(tl.float32)
|
||||
|
||||
one_plus_scale = _round_bf16_to_fp32(1.0 + scale)
|
||||
scaled = _round_bf16_to_fp32(x * one_plus_scale)
|
||||
tl.store(
|
||||
output_ptr + row * stride_x_row + columns,
|
||||
scaled + shift,
|
||||
mask=mask,
|
||||
)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _indexed_gate_bf16_kernel(
|
||||
output_ptr,
|
||||
x_ptr,
|
||||
gate_ptr,
|
||||
other_ptr,
|
||||
indices_ptr,
|
||||
hidden_size,
|
||||
stride_x_row,
|
||||
stride_gate_row,
|
||||
stride_other_row,
|
||||
stride_indices,
|
||||
BLOCK_N: tl.constexpr,
|
||||
):
|
||||
row = tl.program_id(0)
|
||||
columns = tl.arange(0, BLOCK_N)
|
||||
mask = columns < hidden_size
|
||||
index = tl.load(indices_ptr + row * stride_indices)
|
||||
|
||||
x = tl.load(x_ptr + row * stride_x_row + columns, mask=mask, other=0.0).to(
|
||||
tl.float32
|
||||
)
|
||||
gate = tl.load(
|
||||
gate_ptr + index * stride_gate_row + columns, mask=mask, other=0.0
|
||||
).to(tl.float32)
|
||||
other = tl.load(
|
||||
other_ptr + row * stride_other_row + columns, mask=mask, other=0.0
|
||||
).to(tl.float32)
|
||||
|
||||
gated = _round_bf16_to_fp32(gate * other)
|
||||
tl.store(
|
||||
output_ptr + row * stride_x_row + columns,
|
||||
x + gated,
|
||||
mask=mask,
|
||||
)
|
||||
|
||||
|
||||
def indexed_scale_shift_bf16_(
|
||||
x: torch.Tensor,
|
||||
shift: torch.Tensor,
|
||||
scale: torch.Tensor,
|
||||
indices: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
rows, hidden_size = x.shape
|
||||
if rows == 0:
|
||||
return x
|
||||
block_n = triton.next_power_of_2(hidden_size)
|
||||
_indexed_scale_shift_bf16_kernel[(rows,)](
|
||||
x,
|
||||
x,
|
||||
shift,
|
||||
scale,
|
||||
indices,
|
||||
hidden_size,
|
||||
x.stride(0),
|
||||
shift.stride(0),
|
||||
scale.stride(0),
|
||||
indices.stride(0),
|
||||
BLOCK_N=block_n,
|
||||
num_warps=8,
|
||||
)
|
||||
return x
|
||||
|
||||
|
||||
def indexed_gate_bf16_(
|
||||
x: torch.Tensor,
|
||||
gate: torch.Tensor,
|
||||
other: torch.Tensor,
|
||||
indices: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
rows, hidden_size = x.shape
|
||||
if rows == 0:
|
||||
return x
|
||||
block_n = triton.next_power_of_2(hidden_size)
|
||||
_indexed_gate_bf16_kernel[(rows,)](
|
||||
x,
|
||||
x,
|
||||
gate,
|
||||
other,
|
||||
indices,
|
||||
hidden_size,
|
||||
x.stride(0),
|
||||
gate.stride(0),
|
||||
other.stride(0),
|
||||
indices.stride(0),
|
||||
BLOCK_N=block_n,
|
||||
num_warps=8,
|
||||
)
|
||||
return x
|
||||
@@ -5,6 +5,81 @@ import triton.language as tl # type: ignore
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _fp32_mul_add_rn(x, scale, residual):
|
||||
"""Match separate CUDA FP32 multiply and add rounding (no FMA)."""
|
||||
return tl.inline_asm_elementwise(
|
||||
asm="""{
|
||||
.reg .f32 product;
|
||||
mul.rn.f32 product, $1, $2;
|
||||
add.rn.f32 $0, $3, product;
|
||||
}""",
|
||||
constraints="=f,f,f,f",
|
||||
args=(x, scale, residual),
|
||||
dtype=tl.float32,
|
||||
is_pure=True,
|
||||
pack=1,
|
||||
)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _fused_scaled_residual_add_exact_kernel(
|
||||
output_ptr,
|
||||
residual_ptr,
|
||||
x_ptr,
|
||||
scale_ptr,
|
||||
numel: tl.constexpr,
|
||||
width: tl.constexpr,
|
||||
BLOCK_SIZE: tl.constexpr,
|
||||
):
|
||||
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
||||
mask = offsets < numel
|
||||
x = tl.load(x_ptr + offsets, mask=mask).to(tl.float32)
|
||||
scale = tl.load(scale_ptr + offsets % width, mask=mask)
|
||||
residual = tl.load(residual_ptr + offsets, mask=mask)
|
||||
output = _fp32_mul_add_rn(x, scale, residual)
|
||||
tl.store(output_ptr + offsets, output, mask=mask)
|
||||
|
||||
|
||||
def try_fused_scaled_residual_add_exact(
|
||||
residual: torch.Tensor,
|
||||
x: torch.Tensor,
|
||||
scale: torch.Tensor,
|
||||
) -> torch.Tensor | None:
|
||||
"""Fuse ``residual + x * scale`` without changing eager FP32 rounding."""
|
||||
if (
|
||||
not current_platform.is_cuda()
|
||||
or torch.is_grad_enabled()
|
||||
or torch.compiler.is_compiling()
|
||||
or residual.dtype != torch.float32
|
||||
or x.dtype not in (torch.float16, torch.bfloat16)
|
||||
or scale.dtype != torch.float32
|
||||
or not residual.is_cuda
|
||||
or residual.device != x.device
|
||||
or residual.device != scale.device
|
||||
or residual.shape != x.shape
|
||||
or scale.shape != (x.shape[-1],)
|
||||
or not residual.is_contiguous()
|
||||
or not x.is_contiguous()
|
||||
or not scale.is_contiguous()
|
||||
or x.numel() == 0
|
||||
):
|
||||
return None
|
||||
|
||||
output = torch.empty_like(residual)
|
||||
block_size = 1024
|
||||
_fused_scaled_residual_add_exact_kernel[(triton.cdiv(x.numel(), block_size),)](
|
||||
output,
|
||||
residual,
|
||||
x,
|
||||
scale,
|
||||
numel=x.numel(),
|
||||
width=x.shape[-1],
|
||||
BLOCK_SIZE=block_size,
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _fused_layernorm_scale_shift_gate_select01_kernel(
|
||||
output_ptr,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import torch
|
||||
import triton
|
||||
import triton.language as tl
|
||||
|
||||
|
||||
@triton.jit
|
||||
def _pack_qkv_destination_major_kernel(
|
||||
output_ptr,
|
||||
q_ptr,
|
||||
k_ptr,
|
||||
v_ptr,
|
||||
total_elements,
|
||||
rows,
|
||||
local_heads,
|
||||
head_size,
|
||||
stride_q_row,
|
||||
stride_q_head,
|
||||
stride_k_row,
|
||||
stride_k_head,
|
||||
stride_v_row,
|
||||
stride_v_head,
|
||||
BLOCK_SIZE: tl.constexpr,
|
||||
):
|
||||
offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
|
||||
mask = offsets < total_elements
|
||||
|
||||
dim = offsets % head_size
|
||||
head_slot = offsets // head_size
|
||||
local_head = head_slot % local_heads
|
||||
row_slot = head_slot // local_heads
|
||||
row = row_slot % rows
|
||||
destination = row_slot // rows
|
||||
global_head = destination * local_heads + local_head
|
||||
|
||||
q = tl.load(
|
||||
q_ptr + row * stride_q_row + global_head * stride_q_head + dim,
|
||||
mask=mask,
|
||||
)
|
||||
k = tl.load(
|
||||
k_ptr + row * stride_k_row + global_head * stride_k_head + dim,
|
||||
mask=mask,
|
||||
)
|
||||
v = tl.load(
|
||||
v_ptr + row * stride_v_row + global_head * stride_v_head + dim,
|
||||
mask=mask,
|
||||
)
|
||||
output_base = head_slot * (3 * head_size) + dim
|
||||
tl.store(output_ptr + output_base, q, mask=mask)
|
||||
tl.store(output_ptr + output_base + head_size, k, mask=mask)
|
||||
tl.store(output_ptr + output_base + 2 * head_size, v, mask=mask)
|
||||
|
||||
|
||||
def pack_qkv_destination_major(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
world_size: int,
|
||||
) -> torch.Tensor:
|
||||
rows, global_heads, head_size = q.shape
|
||||
local_heads = global_heads // world_size
|
||||
output = torch.empty(
|
||||
world_size,
|
||||
rows,
|
||||
local_heads,
|
||||
3 * head_size,
|
||||
dtype=q.dtype,
|
||||
device=q.device,
|
||||
)
|
||||
total_elements = rows * global_heads * head_size
|
||||
if total_elements == 0:
|
||||
return output
|
||||
|
||||
block_size = 1024
|
||||
_pack_qkv_destination_major_kernel[(triton.cdiv(total_elements, block_size),)](
|
||||
output,
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
total_elements,
|
||||
rows,
|
||||
local_heads,
|
||||
head_size,
|
||||
q.stride(0),
|
||||
q.stride(1),
|
||||
k.stride(0),
|
||||
k.stride(1),
|
||||
v.stride(0),
|
||||
v.stride(1),
|
||||
BLOCK_SIZE=block_size,
|
||||
num_warps=8,
|
||||
)
|
||||
return output
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.kernels.jit.utils import cache_once, load_jit, make_cpp_args
|
||||
from sglang.srt.utils.custom_op import register_custom_op
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tvm_ffi.module import Module
|
||||
|
||||
|
||||
_SUPPORTED_DTYPES = (torch.float16, torch.bfloat16, torch.float32)
|
||||
|
||||
|
||||
@cache_once
|
||||
def _jit_usp_relayout_module(dtype: torch.dtype) -> Module:
|
||||
args = make_cpp_args(dtype)
|
||||
return load_jit(
|
||||
"diffusion_usp_relayout",
|
||||
*args,
|
||||
cuda_files=["diffusion/usp_relayout.cuh"],
|
||||
cuda_wrappers=[
|
||||
(
|
||||
"usp_merge_heads",
|
||||
"sglang_usp_relayout::" f"UspMergeHeadsKernel<{args}>::run",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _fake_merge_heads(x: torch.Tensor) -> torch.Tensor:
|
||||
world, seq, batch, h_local, head_dim = x.shape
|
||||
return x.new_empty((batch, seq, world, h_local, head_dim))
|
||||
|
||||
|
||||
@register_custom_op(
|
||||
op_name="diffusion_usp_merge_heads",
|
||||
mutates_args=[],
|
||||
fake_impl=_fake_merge_heads,
|
||||
)
|
||||
def _usp_merge_heads_custom_op(x: torch.Tensor) -> torch.Tensor:
|
||||
world, seq, batch, h_local, head_dim = x.shape
|
||||
out = x.new_empty((batch, seq, world, h_local, head_dim))
|
||||
module = _jit_usp_relayout_module(x.dtype)
|
||||
module.usp_merge_heads(out, x)
|
||||
return out
|
||||
|
||||
|
||||
def can_use_usp_merge_heads(x: torch.Tensor) -> bool:
|
||||
return (
|
||||
isinstance(x, torch.Tensor)
|
||||
and torch.version.hip is None
|
||||
and x.is_cuda
|
||||
and x.dtype in _SUPPORTED_DTYPES
|
||||
and x.dim() == 5
|
||||
and x.numel() > 0
|
||||
and x.is_contiguous()
|
||||
)
|
||||
|
||||
|
||||
def _usp_merge_heads_cuda(x: torch.Tensor) -> torch.Tensor:
|
||||
"""[W, S, B, h_local, D] -> [B, S, W, h_local, D] contiguous.
|
||||
|
||||
Bit-exact single-pass replacement for
|
||||
``x.permute(2, 1, 0, 3, 4).contiguous()`` on the Ulysses output path.
|
||||
"""
|
||||
if not can_use_usp_merge_heads(x):
|
||||
raise RuntimeError("unsupported input for usp_merge_heads CUDA")
|
||||
return _usp_merge_heads_custom_op(x)
|
||||
|
||||
|
||||
def usp_merge_heads(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Merge Ulysses output heads with an exact eager fallback.
|
||||
|
||||
The backend selection lives here so callers only express the layout
|
||||
transformation. Unsupported devices, layouts, and compiled regions retain
|
||||
the original PyTorch operation.
|
||||
"""
|
||||
if not torch.compiler.is_compiling() and can_use_usp_merge_heads(x):
|
||||
return _usp_merge_heads_cuda(x)
|
||||
return x.permute(2, 1, 0, 3, 4).contiguous()
|
||||
@@ -9,7 +9,7 @@ SGLang diffusion features an end-to-end unified pipeline for accelerating diffus
|
||||
## Key Features
|
||||
|
||||
SGLang Diffusion has the following features:
|
||||
- Broad model support: Wan, FastWan, FLUX, Qwen-Image, Z-Image, Ideogram 4, Krea-2, Cosmos3, LTX-2/LTX-2.3, LingBot World, SANA-WM, JoyEcho, MOVA, GLM-Image, ERNIE-Image, Hunyuan3D, and more
|
||||
- Broad model support: Wan, FastWan, FLUX, Qwen-Image, Z-Image, Ideogram 4, Krea-2, Cosmos3, LTX-2/LTX-2.3, MiniMax-H3, LingBot World, SANA-WM, JoyEcho, MOVA, GLM-Image, ERNIE-Image, Hunyuan3D, and more
|
||||
- Fast inference speed: empowered by optimized `sgl-kernel` kernels, scheduler/runtime improvements, caching acceleration, and native diffusion hot-path optimizations
|
||||
- Ease of use: OpenAI-compatible api, CLI, and python sdk support
|
||||
- Multi-platform support:
|
||||
|
||||
@@ -111,6 +111,21 @@ def _infer_slo_base_time_ms_from_warmups(
|
||||
return float(np.median(candidates_ms)) if candidates_ms else None
|
||||
|
||||
|
||||
def _parse_extra_body(raw: Optional[str]) -> Dict[str, Any]:
|
||||
"""Parses --extra-body, which is merged over every generated payload."""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"--extra-body is not valid JSON: {exc}") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(
|
||||
f"--extra-body must be a JSON object, got {type(parsed).__name__}."
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _populate_slo_ms_from_warmups(
|
||||
requests_list: List[RequestFuncInput], warmup_pairs: List[tuple], args
|
||||
) -> List[RequestFuncInput]:
|
||||
@@ -496,6 +511,10 @@ async def benchmark(args):
|
||||
if args.base_url is None:
|
||||
args.base_url = NetworkAddress(args.host, args.port).to_url()
|
||||
|
||||
# Parsed before the service wait and the dataset download so a malformed
|
||||
# value fails immediately instead of after minutes of setup.
|
||||
extra_body = _parse_extra_body(args.extra_body)
|
||||
|
||||
# Wait for service
|
||||
wait_for_service(args.base_url)
|
||||
|
||||
@@ -571,6 +590,13 @@ async def benchmark(args):
|
||||
requests_list = dataset.get_requests()
|
||||
logger.info(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
|
||||
|
||||
if extra_body:
|
||||
logger.info(f"Merging --extra-body into every request: {extra_body}")
|
||||
requests_list = [
|
||||
replace(req, extra_body={**req.extra_body, **extra_body})
|
||||
for req in requests_list
|
||||
]
|
||||
|
||||
# Limit concurrency
|
||||
if args.max_concurrency is not None:
|
||||
semaphore = asyncio.Semaphore(args.max_concurrency)
|
||||
@@ -588,10 +614,10 @@ async def benchmark(args):
|
||||
# Run warmup requests
|
||||
warmup_pairs: List[tuple] = []
|
||||
if args.warmup_requests and requests_list:
|
||||
# The server always overrides warmup requests to use
|
||||
# num_inference_steps=1 (see Req.set_as_warmup), so we match
|
||||
# that here to keep the benchmark's SLO estimation consistent.
|
||||
warmup_steps = 1
|
||||
# Defaults to 1 to match the server's own boot warmup (see
|
||||
# Req.set_as_warmup) and keep SLO estimation consistent. Raise it
|
||||
# for models that reject a 1-step schedule, such as MiniMax-H3.
|
||||
warmup_steps = args.warmup_inference_steps
|
||||
logger.info(
|
||||
f"Running {args.warmup_requests} warmup request(s) with "
|
||||
f"num_inference_steps={warmup_steps}..."
|
||||
@@ -828,6 +854,22 @@ if __name__ == "__main__":
|
||||
default=1,
|
||||
help="Number of warmup requests to run before measurement.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup-inference-steps",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Denoise steps for warmup requests. Raise it for models that "
|
||||
"reject a 1-step schedule.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-body",
|
||||
type=str,
|
||||
default=None,
|
||||
help="JSON object merged over each JSON request body, for contract "
|
||||
'fields the generic payload omits (e.g. \'{"task": "t2va"}\' for '
|
||||
"MiniMax-H3). Multipart image requests forward it as an extra_body "
|
||||
"form field instead, which the server may not unpack.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-inference-steps",
|
||||
type=int,
|
||||
|
||||
@@ -12,6 +12,7 @@ from sglang.multimodal_gen.configs.models.dits.lingbot_world import (
|
||||
LingBotWorldVideoConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.dits.longlive2 import LongLive2VideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import MiniMaxH3DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_audio import MOVAAudioConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.mova_video import MOVAVideoConfig
|
||||
from sglang.multimodal_gen.configs.models.dits.stablediffusion3 import (
|
||||
@@ -27,6 +28,7 @@ __all__ = [
|
||||
"Ideogram4DistilledDiTConfig",
|
||||
"LingBotWorldVideoConfig",
|
||||
"LongLive2VideoConfig",
|
||||
"MiniMaxH3DiTConfig",
|
||||
"WanVideoConfig",
|
||||
"Hunyuan3DDiTConfig",
|
||||
"MOVAAudioConfig",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.base import DiTArchConfig, DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.fsdp import is_block
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
|
||||
MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT = 64
|
||||
MINIMAX_H3_ADALN_MODALITY_NUM = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3DiTArchConfig(DiTArchConfig):
|
||||
_fsdp_shard_conditions: list = field(default_factory=lambda: [is_block])
|
||||
|
||||
lora_param_names_mapping: dict = field(default_factory=dict)
|
||||
|
||||
_supported_attention_backends: set[AttentionBackendEnum] = field(
|
||||
default_factory=lambda: {
|
||||
AttentionBackendEnum.FA,
|
||||
AttentionBackendEnum.AITER,
|
||||
AttentionBackendEnum.TORCH_SDPA,
|
||||
}
|
||||
)
|
||||
|
||||
num_layers: int = 50
|
||||
token_refiner_num_layers: int = 2
|
||||
hidden_size: int = 5376
|
||||
num_attention_heads: int = 56
|
||||
attention_head_dim: int = 128
|
||||
ffn_hidden_size: int = 14336
|
||||
latents_dim: int = 24
|
||||
audio_latents_dim: int = 32
|
||||
patch_size: tuple[int, int, int] = (1, 2, 2)
|
||||
text_dim: int = 5120
|
||||
timestep_input_dim: int = 256
|
||||
time_embed_hidden_size: int = 5376
|
||||
time_embed_dim: int = 2688
|
||||
adaln_out_features: int = 18 * 5376
|
||||
final_adaln_out_features: int = 2 * 5376
|
||||
rope_inv_freq_len: int = 16
|
||||
norm_eps: float = 1e-5
|
||||
qk_norm_eps: float = 1e-5
|
||||
final_norm_eps: float = 1e-5
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if isinstance(self.patch_size, list):
|
||||
self.patch_size = tuple(self.patch_size)
|
||||
if len(self.patch_size) != 3:
|
||||
raise ValueError(f"patch_size must have 3 values, got {self.patch_size}.")
|
||||
self.num_channels_latents = self.latents_dim
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3DiTConfig(DiTConfig):
|
||||
arch_config: MiniMaxH3DiTArchConfig = field(default_factory=MiniMaxH3DiTArchConfig)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_ADALN_MODALITY_NUM",
|
||||
"MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT",
|
||||
"MiniMaxH3DiTArchConfig",
|
||||
"MiniMaxH3DiTConfig",
|
||||
]
|
||||
@@ -21,6 +21,10 @@ from sglang.multimodal_gen.configs.models.encoders.ideogram import (
|
||||
Ideogram4TextEncoderConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.llama import LlamaConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.minimax_h3_qwen3vl import (
|
||||
MiniMaxH3Qwen3VLArchConfig,
|
||||
MiniMaxH3Qwen3VLConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3 import Qwen3TextConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3vl import Qwen3VLConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.t5 import T5Config
|
||||
@@ -36,6 +40,8 @@ __all__ = [
|
||||
"Flux2MistralTextConfig",
|
||||
"build_flux2_text_messages",
|
||||
"LlamaConfig",
|
||||
"MiniMaxH3Qwen3VLArchConfig",
|
||||
"MiniMaxH3Qwen3VLConfig",
|
||||
"Qwen3TextConfig",
|
||||
"Qwen3VLConfig",
|
||||
"T5Config",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Native Qwen3-VL encoder configuration for MiniMax H3."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3vl import (
|
||||
Qwen3VLArchConfig,
|
||||
Qwen3VLConfig,
|
||||
)
|
||||
|
||||
MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3Qwen3VLArchConfig(Qwen3VLArchConfig):
|
||||
"""The checkpoint is Qwen3-VL-32B, consumed at hidden_states[50]."""
|
||||
|
||||
architectures: list[str] = field(
|
||||
default_factory=lambda: ["MiniMaxH3Qwen3VLEncoder"]
|
||||
)
|
||||
hidden_size: int = 5120
|
||||
intermediate_size: int = 25600
|
||||
num_hidden_layers: int = MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER
|
||||
num_attention_heads: int = 64
|
||||
num_key_value_heads: int = 8
|
||||
head_dim: int = 128
|
||||
text_len: int = 262144
|
||||
hidden_state_skip_layer: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3Qwen3VLConfig(Qwen3VLConfig):
|
||||
arch_config: MiniMaxH3Qwen3VLArchConfig = field(
|
||||
default_factory=MiniMaxH3Qwen3VLArchConfig
|
||||
)
|
||||
|
||||
def post_diffusers_config_update(self) -> None:
|
||||
"""Select the in-tree extractor after loading the HF architecture."""
|
||||
|
||||
arch = self.arch_config
|
||||
arch.architectures = ["MiniMaxH3Qwen3VLEncoder"]
|
||||
arch.hidden_size = int(arch.text_config.hidden_size)
|
||||
arch.intermediate_size = int(arch.text_config.intermediate_size)
|
||||
arch.num_attention_heads = int(arch.text_config.num_attention_heads)
|
||||
arch.num_key_value_heads = int(arch.text_config.num_key_value_heads)
|
||||
arch.head_dim = int(arch.text_config.head_dim)
|
||||
arch.num_hidden_layers = MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER
|
||||
arch.text_config.num_hidden_layers = MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER
|
||||
arch.text_config.output_hidden_states = False
|
||||
arch.text_config.use_cache = False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER",
|
||||
"MiniMaxH3Qwen3VLArchConfig",
|
||||
"MiniMaxH3Qwen3VLConfig",
|
||||
]
|
||||
@@ -3,6 +3,12 @@
|
||||
from sglang.multimodal_gen.configs.models.vaes.dac import DacVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.hunyuan3d import Hunyuan3DVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.hunyuanvae import HunyuanVAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_audio import (
|
||||
MiniMaxH3AudioVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.stablediffusion3 import (
|
||||
StableDiffusion3VAEConfig,
|
||||
)
|
||||
@@ -11,6 +17,8 @@ from sglang.multimodal_gen.configs.models.vaes.wanvae import WanVAEConfig
|
||||
__all__ = [
|
||||
"DacVAEConfig",
|
||||
"HunyuanVAEConfig",
|
||||
"MiniMaxH3AudioVAEConfig",
|
||||
"MiniMaxH3VideoVAEConfig",
|
||||
"StableDiffusion3VAEConfig",
|
||||
"WanVAEConfig",
|
||||
"Hunyuan3DVAEConfig",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.base import VAEArchConfig, VAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_contract import (
|
||||
validate_minimax_h3_vae_latent_stats,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioVAEArchConfig(VAEArchConfig):
|
||||
sample_rate: int = 32000
|
||||
latent_channels: int = 32
|
||||
latents_mean: list[float] | None = None
|
||||
latents_std: list[float] | None = None
|
||||
output_channel: int = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3AudioVAEConfig(VAEConfig):
|
||||
arch_config: MiniMaxH3AudioVAEArchConfig = field(
|
||||
default_factory=MiniMaxH3AudioVAEArchConfig
|
||||
)
|
||||
load_encoder: bool = True
|
||||
load_decoder: bool = True
|
||||
|
||||
def post_init(self) -> None:
|
||||
validate_minimax_h3_vae_latent_stats(
|
||||
self.arch_config,
|
||||
component_name="audio_vae",
|
||||
expected_channels=32,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3AudioVAEArchConfig", "MiniMaxH3AudioVAEConfig"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class MiniMaxH3LatentStatsConfig(Protocol):
|
||||
latent_channels: int
|
||||
latents_mean: list[float] | None
|
||||
latents_std: list[float] | None
|
||||
|
||||
|
||||
class MiniMaxH3VAEContractError(ValueError):
|
||||
def __init__(self, component_name: str, detail: str) -> None:
|
||||
super().__init__(f"MiniMax H3 {component_name} {detail}")
|
||||
self.component_name = component_name
|
||||
self.detail = detail
|
||||
|
||||
def __reduce__(self):
|
||||
# BaseException pickles via cls(*args); rebuild from the two ctor args
|
||||
# so the error propagates cleanly across process boundaries.
|
||||
return (type(self), (self.component_name, self.detail))
|
||||
|
||||
|
||||
def validate_minimax_h3_vae_latent_stats(
|
||||
arch_config: MiniMaxH3LatentStatsConfig,
|
||||
component_name: str,
|
||||
expected_channels: int,
|
||||
) -> None:
|
||||
if arch_config.latent_channels != expected_channels:
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
"latent_channels must be "
|
||||
f"{expected_channels}, got {arch_config.latent_channels!r}",
|
||||
)
|
||||
|
||||
for field_name, values in (
|
||||
("latents_mean", arch_config.latents_mean),
|
||||
("latents_std", arch_config.latents_std),
|
||||
):
|
||||
if values is None:
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
f"config.json missing {field_name}",
|
||||
)
|
||||
if not isinstance(values, list) or not all(
|
||||
isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
for value in values
|
||||
):
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
f"config.json {field_name} must be a list of numbers",
|
||||
)
|
||||
if len(values) != expected_channels:
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
f"config.json {field_name} must contain exactly "
|
||||
f"{expected_channels} values, got {len(values)}",
|
||||
)
|
||||
if field_name == "latents_mean" and not all(
|
||||
math.isfinite(value) for value in values
|
||||
):
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
"config.json latents_mean values must be finite",
|
||||
)
|
||||
if field_name == "latents_std" and not all(
|
||||
math.isfinite(value) and value > 0 for value in values
|
||||
):
|
||||
raise MiniMaxH3VAEContractError(
|
||||
component_name,
|
||||
"config.json latents_std values must be finite and greater than zero",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3VAEContractError",
|
||||
"validate_minimax_h3_vae_latent_stats",
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.base import VAEArchConfig, VAEConfig
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_contract import (
|
||||
validate_minimax_h3_vae_latent_stats,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3VideoVAEArchConfig(VAEArchConfig):
|
||||
latent_channels: int = 24
|
||||
latents_mean: list[float] | None = None
|
||||
latents_std: list[float] | None = None
|
||||
temporal_compression_ratio: int = 4
|
||||
spatial_compression_ratio: int = 16
|
||||
vae_clip_length: int = 17
|
||||
vae_token_drop: int = 3
|
||||
vae_encoder_tiling: int = 1
|
||||
vae_decoder_tiling: int = 1
|
||||
vae_parallel_tiling: int = 1
|
||||
vae_tile_size: int = 256
|
||||
vae_tile_overlap_min: int = 64
|
||||
vae_chunk_dim: int = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3VideoVAEConfig(VAEConfig):
|
||||
arch_config: MiniMaxH3VideoVAEArchConfig = field(
|
||||
default_factory=MiniMaxH3VideoVAEArchConfig
|
||||
)
|
||||
load_encoder: bool = True
|
||||
load_decoder: bool = True
|
||||
use_tiling: bool = True
|
||||
use_parallel_tiling: bool = True
|
||||
# The released checkpoint's quality contract uses overlapping latent
|
||||
# tiles. Parallel tiling distributes whole tiles without changing that
|
||||
# recipe. Spatial-shard decode is rejected because validation found output
|
||||
# mismatches on H3.
|
||||
parallel_decode_mode: str = "tiled"
|
||||
|
||||
def resolved_parallel_decode_mode(self) -> str:
|
||||
if self.parallel_decode_mode == "auto":
|
||||
return "tiled"
|
||||
if self.parallel_decode_mode in ("spatial", "spatial_shard"):
|
||||
raise ValueError(
|
||||
"MiniMax H3 rejects spatial-shard VAE decode because it failed "
|
||||
"the released quality contract; use tiled"
|
||||
)
|
||||
if self.parallel_decode_mode == "tiled":
|
||||
return "tiled"
|
||||
if self.parallel_decode_mode == "patch":
|
||||
raise ValueError("MiniMax H3 does not support patch VAE decode; use tiled")
|
||||
raise ValueError(
|
||||
f"unsupported MiniMax H3 VAE parallel decode mode "
|
||||
f"{self.parallel_decode_mode!r}"
|
||||
)
|
||||
|
||||
def post_init(self) -> None:
|
||||
self.resolved_parallel_decode_mode()
|
||||
validate_minimax_h3_vae_latent_stats(
|
||||
self.arch_config,
|
||||
component_name="video_vae",
|
||||
expected_channels=24,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3VideoVAEArchConfig", "MiniMaxH3VideoVAEConfig"]
|
||||
@@ -40,6 +40,9 @@ from sglang.multimodal_gen.configs.pipeline_configs.ltx_2 import (
|
||||
LTX2PipelineConfig,
|
||||
LTX23PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.minimax_h3 import (
|
||||
MiniMaxH3PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.mova import MOVAPipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.pi05 import Pi05PipelineConfig
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.sana import SanaPipelineConfig
|
||||
@@ -86,4 +89,5 @@ __all__ = [
|
||||
"LTX23PipelineConfig",
|
||||
"LingBotWorldCausalDMDConfig",
|
||||
"LingBotWorldV2CausalDMDConfig",
|
||||
"MiniMaxH3PipelineConfig",
|
||||
]
|
||||
|
||||
@@ -267,6 +267,11 @@ class PipelineConfig:
|
||||
# return the model-specific config for optimal deployment setting
|
||||
return ModelDeploymentConfig()
|
||||
|
||||
def validate_server_args(self, server_args: Any) -> None:
|
||||
"""Validate model-owned constraints after server args are normalized."""
|
||||
|
||||
del server_args
|
||||
|
||||
# Wan2.2 TI2V parameters
|
||||
boundary_ratio: float | None = None
|
||||
|
||||
@@ -394,6 +399,11 @@ class PipelineConfig:
|
||||
"""
|
||||
return self.task_type in (ModelTaskType.T2I, ModelTaskType.T2V)
|
||||
|
||||
def supports_disaggregation(self) -> bool:
|
||||
"""Return whether multi-service disaggregated deployment is supported."""
|
||||
|
||||
return True
|
||||
|
||||
def supports_native_grouped_requests(self):
|
||||
"""Return whether dynamic batches should run as grouped Req lists."""
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import MiniMaxH3DiTConfig
|
||||
from sglang.multimodal_gen.configs.models.encoders.minimax_h3_qwen3vl import (
|
||||
MiniMaxH3Qwen3VLConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_audio import (
|
||||
MiniMaxH3AudioVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.base import (
|
||||
ModelTaskType,
|
||||
PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.model_deployment_config import (
|
||||
ModelDeploymentConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3PipelineConfig(PipelineConfig):
|
||||
"""MiniMax H3 native audio-video pipeline configuration."""
|
||||
|
||||
# Canonical H3 materials are prepared by the model-specific stages. The
|
||||
# generic TI2V image resize would both duplicate that work and overwrite
|
||||
# the already-resolved target canvas.
|
||||
skip_input_image_preprocess: bool = True
|
||||
native_only_components = (
|
||||
"text_encoder",
|
||||
"transformer",
|
||||
"video_vae",
|
||||
"audio_vae",
|
||||
)
|
||||
task_type: ModelTaskType = ModelTaskType.TI2V
|
||||
dit_config: MiniMaxH3DiTConfig = field(default_factory=MiniMaxH3DiTConfig)
|
||||
vae_config: MiniMaxH3VideoVAEConfig = field(default_factory=MiniMaxH3VideoVAEConfig)
|
||||
audio_vae_config: MiniMaxH3AudioVAEConfig = field(
|
||||
default_factory=MiniMaxH3AudioVAEConfig
|
||||
)
|
||||
dit_precision: str = "bf16"
|
||||
# The video VAE remains fp32-resident because it also encodes keyframes.
|
||||
# Decode follows the released fp16-autocast recipe unless the user
|
||||
# explicitly disables autocast.
|
||||
vae_precision: str = "fp32"
|
||||
vae_decode_precision: str = "fp16"
|
||||
audio_vae_precision: str = "fp32"
|
||||
text_encoder_configs: tuple[MiniMaxH3Qwen3VLConfig, ...] = field(
|
||||
default_factory=lambda: (MiniMaxH3Qwen3VLConfig(),)
|
||||
)
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16",))
|
||||
text_encoder_extra_args: list[dict] = field(default_factory=lambda: [{}])
|
||||
# The released checkpoint is CFG-distilled and has one positive branch.
|
||||
should_use_guidance: bool = False
|
||||
output_audio_sample_rate: int | None = 32000
|
||||
output_audio_channels: int | None = 2
|
||||
output_av_drift_tolerance_s: float | None = 0.25
|
||||
|
||||
def accepts_audio_input(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_disaggregation(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def requires_audio_output(self) -> bool:
|
||||
return True
|
||||
|
||||
def get_model_deployment_config(self) -> ModelDeploymentConfig:
|
||||
return ModelDeploymentConfig(
|
||||
speed_mode_enable_torch_compile_by_default=False,
|
||||
keep_resident_min_available_gb=120,
|
||||
keep_resident_components=("dit", "text_encoder", "vae"),
|
||||
auto_enable_cfg_parallel=False,
|
||||
supports_cfg_parallel=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _server_arg_value(value):
|
||||
return getattr(value, "value", value)
|
||||
|
||||
def validate_quality_deployment(self, server_args) -> None:
|
||||
"""Fail closed unless the resident server matches the measured profile."""
|
||||
|
||||
attention_backend = self._server_arg_value(server_args.attention_backend)
|
||||
attention_backend = (
|
||||
str(attention_backend).strip().lower()
|
||||
if attention_backend is not None
|
||||
else None
|
||||
)
|
||||
capability = current_platform.get_device_capability()
|
||||
capability_int = capability.to_int() if capability is not None else None
|
||||
device_name = (
|
||||
current_platform.get_device_name()
|
||||
if current_platform.is_cuda()
|
||||
else type(current_platform).__name__
|
||||
)
|
||||
model_variant = str(server_args.model_variant or "fl2va").lower()
|
||||
actual = {
|
||||
"attention_backend": attention_backend,
|
||||
"backend": self._server_arg_value(server_args.backend),
|
||||
"component_attention_backends": {},
|
||||
"enable_breakable_cuda_graph": server_args.enable_breakable_cuda_graph,
|
||||
"enable_torch_compile": server_args.enable_torch_compile,
|
||||
"is_dit_layerwise_offload_selected": (
|
||||
server_args.is_dit_layerwise_offload_selected
|
||||
),
|
||||
"model_variant": model_variant,
|
||||
"num_gpus": server_args.num_gpus,
|
||||
"performance_mode": server_args.performance_mode,
|
||||
"quantization": server_args.quantization,
|
||||
"regional_compile": server_args.regional_compile,
|
||||
"ring_degree": server_args.ring_degree,
|
||||
"sp_degree": server_args.sp_degree,
|
||||
"tp_size": server_args.tp_size,
|
||||
"ulysses_degree": server_args.ulysses_degree,
|
||||
"use_fsdp_inference": server_args.use_fsdp_inference,
|
||||
}
|
||||
actual["component_attention_backends"] = dict(
|
||||
server_args.component_attention_backends or {}
|
||||
)
|
||||
expected = {
|
||||
"attention_backend": {None, "fa"},
|
||||
"backend": {"auto", "sglang"},
|
||||
"component_attention_backends": {},
|
||||
"enable_breakable_cuda_graph": False,
|
||||
"enable_torch_compile": False,
|
||||
"is_dit_layerwise_offload_selected": False,
|
||||
"model_variant": "fl2va",
|
||||
"num_gpus": 4,
|
||||
"performance_mode": "speed",
|
||||
"quantization": None,
|
||||
"regional_compile": False,
|
||||
"ring_degree": 1,
|
||||
"sp_degree": 4,
|
||||
"tp_size": 1,
|
||||
"ulysses_degree": 4,
|
||||
"use_fsdp_inference": False,
|
||||
}
|
||||
mismatches = {
|
||||
name: {"expected": wanted, "actual": actual[name]}
|
||||
for name, wanted in expected.items()
|
||||
if (
|
||||
actual[name] not in wanted
|
||||
if isinstance(wanted, set)
|
||||
else actual[name] != wanted
|
||||
)
|
||||
}
|
||||
if (
|
||||
not current_platform.is_cuda()
|
||||
or "H200" not in device_name.upper()
|
||||
or capability_int != 90
|
||||
):
|
||||
mismatches["device"] = {
|
||||
"expected": "NVIDIA H200 (compute capability 9.0)",
|
||||
"actual": f"{device_name} (compute capability {capability_int})",
|
||||
}
|
||||
if mismatches:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 approximate quality profiles are validated only for "
|
||||
f"the strict 4xH200 fl2va deployment; mismatches: {mismatches}"
|
||||
)
|
||||
|
||||
def validate_server_args(self, server_args) -> None:
|
||||
# Reject known-inexact VAE modes before any large component download.
|
||||
self.vae_config.resolved_parallel_decode_mode()
|
||||
attention_backend = self._server_arg_value(server_args.attention_backend)
|
||||
if str(attention_backend).strip().lower() == "sage_attn":
|
||||
raise ValueError(
|
||||
"MiniMax-H3 does not support SageAttention: the current packed "
|
||||
"varlen path does not preserve model output"
|
||||
)
|
||||
|
||||
def select_vae_weight_files(
|
||||
self,
|
||||
safetensors_list: list[str],
|
||||
component_model_path: str,
|
||||
component_name: str,
|
||||
vae_precision: str,
|
||||
) -> list[str]:
|
||||
if component_name == "video_vae":
|
||||
return [os.path.join(component_model_path, "source", "model.safetensors")]
|
||||
return safetensors_list
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3PipelineConfig"]
|
||||
@@ -25,6 +25,10 @@ class ModelDeploymentConfig:
|
||||
auto_enable_cfg_parallel: bool = True
|
||||
# degree 1 keeps CFG parallel disabled and leaves GPUs available for SP
|
||||
auto_cfg_parallel_degree_by_num_gpus: tuple[tuple[int, int], ...] = ()
|
||||
# Let performance_mode=speed opt into torch.compile unless the model has
|
||||
# established that the compiled path changes its numerical contract.
|
||||
speed_mode_enable_torch_compile_by_default: bool = True
|
||||
supports_cfg_parallel: bool = True
|
||||
|
||||
def get_auto_cfg_parallel_degree(self, num_gpus: int) -> int:
|
||||
for candidate_num_gpus, cfg_degree in self.auto_cfg_parallel_degree_by_num_gpus:
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
|
||||
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
|
||||
|
||||
_MINIMAX_H3_MAX_SIGNED_SEED = (1 << 63) - 1
|
||||
|
||||
|
||||
def _optional_unit_float(value: Any, field_name: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field_name} must be a number")
|
||||
out = float(value)
|
||||
if out < 0.0 or out > 1.0:
|
||||
raise ValueError(f"{field_name} must be in [0, 1]")
|
||||
return out
|
||||
|
||||
|
||||
def _optional_positive_finite_float(value: Any, field_name: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field_name} must be a number")
|
||||
out = float(value)
|
||||
if not math.isfinite(out) or out <= 0.0:
|
||||
raise ValueError(f"{field_name} must be a positive finite number")
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiniMaxH3SamplingParams(SamplingParams):
|
||||
height: int = 512
|
||||
width: int = 896
|
||||
num_inference_steps: int = 50
|
||||
num_frames: int = field(default=1, init=False)
|
||||
fps: int = field(default=24, init=False)
|
||||
negative_prompt: None = field(default=None, init=False)
|
||||
guidance_scale: float = field(default=1.0, init=False)
|
||||
guidance_scale_2: None = field(default=None, init=False)
|
||||
true_cfg_scale: None = field(default=None, init=False)
|
||||
guidance_rescale: float = field(default=0.0, init=False)
|
||||
cfg_normalization: float = field(default=0.0, init=False)
|
||||
imgvid_cond_noise_aug_for_inference: float | None = None
|
||||
audio_cond_noise_aug_for_inference: float | None = None
|
||||
task: str | None = None
|
||||
conditions: list[dict[str, Any]] | None = None
|
||||
target: dict[str, Any] | None = None
|
||||
audio_flow_shift: float | None = None
|
||||
output_mode: str | None = field(
|
||||
default=None,
|
||||
metadata={"batch_sig_exclude": True},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def video_request_extra_fields(cls) -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"task",
|
||||
"conditions",
|
||||
"target",
|
||||
"audio_flow_shift",
|
||||
"audio_guidance_scale",
|
||||
"quality",
|
||||
"output_mode",
|
||||
"imgvid_cond_noise_aug_for_inference",
|
||||
"audio_cond_noise_aug_for_inference",
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _video_hooks():
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.video_adapter import (
|
||||
MiniMaxH3VideoModelAdapter,
|
||||
)
|
||||
|
||||
return MiniMaxH3VideoModelAdapter()
|
||||
|
||||
@classmethod
|
||||
def lower_video_request_kwargs(
|
||||
cls,
|
||||
request: Any,
|
||||
kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return cls._video_hooks().lower_video_request_kwargs(request, kwargs)
|
||||
|
||||
def prepare_video_request_for_queue(self, req: Any) -> None:
|
||||
hooks = self._video_hooks()
|
||||
hooks.validate_sampling_params(self)
|
||||
hooks.prepare_for_queue_sync(req)
|
||||
|
||||
def expand_video_request_outputs_for_queue(self, req: Any) -> list[Any]:
|
||||
"""Use the same independent-seed grouped path in serve and generate."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.entrypoints.utils import (
|
||||
expand_request_outputs,
|
||||
)
|
||||
|
||||
return expand_request_outputs(req)
|
||||
|
||||
def prepare_synthetic_warmup_request_for_queue(
|
||||
self, req: Any, server_args: Any
|
||||
) -> None:
|
||||
"""Lower generic warmup into one valid native partition request.
|
||||
|
||||
This intentionally calls the existing pre-queue resolver directly
|
||||
instead of the public video admission hook: synthetic warmup disables
|
||||
file delivery, while the public H3 contract correctly requires it.
|
||||
"""
|
||||
selected_variant = getattr(server_args, "model_variant", None)
|
||||
if selected_variant is not None:
|
||||
selected_partition = str(selected_variant).strip().lower()
|
||||
else:
|
||||
selected_path = server_args.model_subfolder or server_args.model_path
|
||||
selected_partition = os.path.basename(
|
||||
os.path.normpath(str(selected_path))
|
||||
).lower()
|
||||
|
||||
if selected_partition == "ref2va":
|
||||
image_path = req.image_path
|
||||
if isinstance(image_path, list):
|
||||
if not image_path:
|
||||
raise ValueError(
|
||||
"MiniMax H3 Ref2VA synthetic warmup requires an image"
|
||||
)
|
||||
image_path = image_path[0]
|
||||
if not isinstance(image_path, str) or not image_path:
|
||||
raise ValueError("MiniMax H3 Ref2VA synthetic warmup requires an image")
|
||||
task = "ref2va"
|
||||
conditions = [
|
||||
{
|
||||
"type": "image",
|
||||
"uri": image_path,
|
||||
"role": "reference",
|
||||
}
|
||||
]
|
||||
else:
|
||||
task = "t2va"
|
||||
conditions = []
|
||||
|
||||
self.task = task
|
||||
self.conditions = conditions
|
||||
self.target = {
|
||||
"short_edge": 768,
|
||||
"aspect_ratio": "16:9",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
selected_seed = req.seed if isinstance(req.seed, int) else int(req.seed[0])
|
||||
req.extra.update(self.build_request_extra(_seed_override=int(selected_seed)))
|
||||
self._video_hooks().prepare_for_queue_sync(req)
|
||||
|
||||
def project_video_queued_job_fields(self, req: Any) -> dict[str, str]:
|
||||
return self._video_hooks().project_queued_job_fields(req)
|
||||
|
||||
def validate_video_final_outputs(
|
||||
self,
|
||||
output_paths: list[str],
|
||||
req: Any,
|
||||
) -> dict[str, str]:
|
||||
return self._video_hooks().validate_final_outputs_sync(output_paths, req)
|
||||
|
||||
def cleanup_video_request(self, req: Any) -> None:
|
||||
self._video_hooks().cleanup_request_sync(req)
|
||||
|
||||
def _adjust(self, server_args) -> None:
|
||||
"""Apply generic path/output adjustments without deriving time shape.
|
||||
|
||||
The generic helper normally rewrites ``num_frames`` for temporal VAE
|
||||
alignment and GPU sharding. MiniMax H3 resolves that shape from the
|
||||
canonical target during pre-queue admission, so those rewrites must
|
||||
not leak into the offline parameter object. Keep the transport
|
||||
metadata at its internal sentinel values until pre-queue populates the
|
||||
actual batch shape.
|
||||
"""
|
||||
|
||||
super()._adjust(server_args)
|
||||
self.fps = 24
|
||||
self.num_frames = 1
|
||||
|
||||
def _validate(self) -> None:
|
||||
self.fps = 24
|
||||
self.num_frames = 1
|
||||
if isinstance(self.target, Mapping):
|
||||
self.target = {
|
||||
field_name: self.target[field_name]
|
||||
for field_name in (
|
||||
"short_edge",
|
||||
"aspect_ratio",
|
||||
"duration_seconds",
|
||||
)
|
||||
if field_name in self.target
|
||||
}
|
||||
super()._validate()
|
||||
_optional_positive_finite_float(self.flow_shift, "flow_shift")
|
||||
_optional_positive_finite_float(self.audio_flow_shift, "audio_flow_shift")
|
||||
if self.enable_frame_interpolation:
|
||||
raise ValueError(
|
||||
"MiniMax H3 does not support enable_frame_interpolation: the "
|
||||
"accepted delivery contract is the canonical 24 fps output"
|
||||
)
|
||||
if self.enable_upscaling:
|
||||
raise ValueError(
|
||||
"MiniMax H3 does not support enable_upscaling: the accepted "
|
||||
"delivery contract is the resolved target canvas"
|
||||
)
|
||||
if self.enable_teacache:
|
||||
raise ValueError(
|
||||
"MiniMax H3 does not support enable_teacache: its packed "
|
||||
"video/audio denoise loop has no lossless TeaCache contract"
|
||||
)
|
||||
if self.rollout:
|
||||
raise ValueError(
|
||||
"MiniMax H3 does not support rollout: its coupled video/audio "
|
||||
"scheduler has no SchedulerRLMixin contract"
|
||||
)
|
||||
if self.return_trajectory_latents or self.return_trajectory_decoded:
|
||||
raise ValueError(
|
||||
"MiniMax H3 does not support trajectory output for its coupled "
|
||||
"video/audio denoise state"
|
||||
)
|
||||
seeds = self.seed if isinstance(self.seed, list) else [self.seed]
|
||||
for seed in seeds:
|
||||
if seed > _MINIMAX_H3_MAX_SIGNED_SEED:
|
||||
raise ValueError(
|
||||
"MiniMax H3 seed must not exceed the signed int64 maximum, "
|
||||
f"got {seed}"
|
||||
)
|
||||
if (
|
||||
isinstance(self.seed, int)
|
||||
and self.seed + self.num_outputs_per_prompt - 1
|
||||
> _MINIMAX_H3_MAX_SIGNED_SEED
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 scalar seed plus output index must fit the "
|
||||
"signed int64 upper bound"
|
||||
)
|
||||
|
||||
def build_request_extra(self, *, _seed_override: int | None = None) -> dict:
|
||||
_optional_unit_float(
|
||||
self.imgvid_cond_noise_aug_for_inference,
|
||||
"imgvid_cond_noise_aug_for_inference",
|
||||
)
|
||||
_optional_unit_float(
|
||||
self.audio_cond_noise_aug_for_inference,
|
||||
"audio_cond_noise_aug_for_inference",
|
||||
)
|
||||
extra = super().build_request_extra()
|
||||
if self.task is not None:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.request_validation import (
|
||||
minimax_h3_validate_canonical_request,
|
||||
)
|
||||
|
||||
extra["minimax_h3_canonical_request"] = (
|
||||
minimax_h3_validate_canonical_request(
|
||||
task=self.task,
|
||||
prompt=self.prompt,
|
||||
conditions=self.conditions,
|
||||
target=self.target,
|
||||
flow_shift=self.flow_shift,
|
||||
audio_flow_shift=self.audio_flow_shift,
|
||||
seed=(
|
||||
_seed_override
|
||||
if _seed_override is not None
|
||||
else self.seed if isinstance(self.seed, int) else None
|
||||
),
|
||||
)
|
||||
)
|
||||
elif (
|
||||
self.conditions is not None
|
||||
or self.target is not None
|
||||
or self.flow_shift is not None
|
||||
or self.audio_flow_shift is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"task is required when conditions/target/flow_shift/"
|
||||
"audio_flow_shift are provided"
|
||||
)
|
||||
return extra
|
||||
|
||||
def refresh_request_extra_after_output_expansion(self, req: Any) -> None:
|
||||
"""Copy validated canonical identity with the selected scalar Req seed."""
|
||||
|
||||
selected_seed = getattr(req, "seed", None)
|
||||
if isinstance(selected_seed, int):
|
||||
canonical_key = "minimax_h3_canonical_request"
|
||||
canonical = dict(req.extra[canonical_key])
|
||||
canonical["seed"] = selected_seed
|
||||
req.extra[canonical_key] = canonical
|
||||
resolved_plan_key = "minimax_h3_resolved_plan"
|
||||
resolved_plan = req.extra.get(resolved_plan_key)
|
||||
if resolved_plan is not None:
|
||||
req.extra[resolved_plan_key] = msgspec.structs.replace(
|
||||
resolved_plan, seed=selected_seed
|
||||
)
|
||||
else:
|
||||
req.extra.update(self.build_request_extra())
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3SamplingParams"]
|
||||
@@ -123,6 +123,10 @@ class SamplingParams:
|
||||
)
|
||||
output_quality: str | None = "default"
|
||||
output_compression: int | None = None
|
||||
# Model-owned, request-scoped approximate acceleration profile. Models
|
||||
# that support it must validate the deployment and workload explicitly.
|
||||
# It intentionally participates in the dynamic-batch signature.
|
||||
quality: str = "lossless"
|
||||
|
||||
# Frame interpolation
|
||||
enable_frame_interpolation: bool = False
|
||||
@@ -328,6 +332,64 @@ class SamplingParams:
|
||||
if self.realtime_chunk_size is not None:
|
||||
req.realtime_chunk_size = self.realtime_chunk_size
|
||||
|
||||
@classmethod
|
||||
def video_request_extra_fields(cls) -> frozenset[str]:
|
||||
"""Declare model-specific multipart video fields accepted by this type."""
|
||||
|
||||
return frozenset()
|
||||
|
||||
@classmethod
|
||||
def lower_video_request_kwargs(
|
||||
cls,
|
||||
request: Any,
|
||||
kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Adapt generic video-API kwargs before constructing this params type."""
|
||||
del request
|
||||
return kwargs
|
||||
|
||||
def prepare_video_request_for_queue(self, req: Any) -> None:
|
||||
"""Resolve model-specific admission facts before a video job is queued."""
|
||||
del req
|
||||
|
||||
def expand_video_request_outputs_for_queue(self, req: Any) -> list[Any] | None:
|
||||
"""Return per-output requests when a model owns grouped execution.
|
||||
|
||||
``None`` preserves the default model-native ``num_outputs`` handling.
|
||||
Models that need the framework's independent-seed request expansion
|
||||
can opt in after their shared pre-queue work has completed.
|
||||
"""
|
||||
del req
|
||||
return None
|
||||
|
||||
def prepare_synthetic_warmup_request_for_queue(
|
||||
self, req: Any, server_args: Any
|
||||
) -> None:
|
||||
"""Resolve model-specific facts for one synthetic warmup request."""
|
||||
del req, server_args
|
||||
|
||||
def project_video_queued_job_fields(self, req: Any) -> dict[str, str]:
|
||||
"""Return model-resolved fields to publish with the queued video job."""
|
||||
del req
|
||||
return {}
|
||||
|
||||
def validate_video_final_outputs(
|
||||
self,
|
||||
output_paths: list[str],
|
||||
req: Any,
|
||||
) -> dict[str, str]:
|
||||
"""Validate final files and return truthful completion metadata."""
|
||||
del output_paths, req
|
||||
return {}
|
||||
|
||||
def cleanup_video_request(self, req: Any) -> None:
|
||||
"""Release request-scoped resources owned by the model integration."""
|
||||
del req
|
||||
|
||||
def refresh_request_extra_after_output_expansion(self, req: Any) -> None:
|
||||
"""Refresh request identity after assigning a per-output seed."""
|
||||
del req
|
||||
|
||||
def _adjust_output_quality(self, output_quality: str, data_type: DataType) -> int:
|
||||
"""Convert output_quality string to compression level."""
|
||||
if data_type == DataType.ACTION:
|
||||
@@ -346,6 +408,11 @@ class SamplingParams:
|
||||
f"prompt_path must be a txt file, got {self.prompt_path!r}"
|
||||
)
|
||||
|
||||
if not isinstance(self.quality, str) or not self.quality.strip():
|
||||
raise ValueError(
|
||||
f"quality must be a non-empty string, got {self.quality!r}"
|
||||
)
|
||||
|
||||
# These are always required to be sane regardless of pipeline.
|
||||
if (
|
||||
not isinstance(self.num_outputs_per_prompt, int)
|
||||
@@ -862,10 +929,20 @@ class SamplingParams:
|
||||
type=int,
|
||||
help="Output compression level (0-100, higher means better quality but larger file size)",
|
||||
)
|
||||
add_argument(
|
||||
"--quality",
|
||||
type=str,
|
||||
help=(
|
||||
"Select a model-owned quality/performance profile. "
|
||||
"Support and validated deployment constraints are model-specific."
|
||||
),
|
||||
)
|
||||
add_argument(
|
||||
"--num-outputs-per-prompt",
|
||||
"--num-outputs",
|
||||
dest="num_outputs_per_prompt",
|
||||
type=int,
|
||||
help="Number of outputs to generate per prompt",
|
||||
help="Number of outputs to generate per prompt (alias: --num-outputs)",
|
||||
)
|
||||
add_argument(
|
||||
"--seed",
|
||||
|
||||
@@ -37,6 +37,7 @@ from sglang.multimodal_gen.configs.pipeline_configs import (
|
||||
HunyuanConfig,
|
||||
LingBotWorldCausalDMDConfig,
|
||||
LingBotWorldV2CausalDMDConfig,
|
||||
MiniMaxH3PipelineConfig,
|
||||
WanI2V480PConfig,
|
||||
WanI2V720PConfig,
|
||||
WanT2V480PConfig,
|
||||
@@ -141,6 +142,7 @@ from sglang.multimodal_gen.configs.sample.ltx_2 import (
|
||||
LTX23HQSamplingParams,
|
||||
LTX23SamplingParams,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.minimax_h3 import MiniMaxH3SamplingParams
|
||||
from sglang.multimodal_gen.configs.sample.mova import (
|
||||
MOVA_360P_SamplingParams,
|
||||
MOVA_720P_SamplingParams,
|
||||
@@ -826,6 +828,18 @@ def _register_configs():
|
||||
lambda hf_id: "mova" in hf_id.lower() and "720p" in hf_id.lower()
|
||||
],
|
||||
)
|
||||
register_configs(
|
||||
sampling_param_cls=MiniMaxH3SamplingParams,
|
||||
pipeline_config_cls=MiniMaxH3PipelineConfig,
|
||||
hf_model_paths=[
|
||||
"MiniMaxAI/MiniMax-H3",
|
||||
"MiniMax/MiniMax-H3",
|
||||
],
|
||||
model_detectors=[
|
||||
lambda model_id: "minimaxh3"
|
||||
in model_id.lower().replace("-", "").replace("_", "")
|
||||
],
|
||||
)
|
||||
# FLUX
|
||||
register_configs(
|
||||
sampling_param_cls=FluxSamplingParams,
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Copyright 2023-2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
# ==============================================================================
|
||||
"""MiniMax H3 breakable CUDA graph packed-prompt padding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import (
|
||||
MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.breakable_cuda_graph import (
|
||||
prompt_padding as bcg_utils,
|
||||
)
|
||||
|
||||
|
||||
def is_minimax_h3_transformer(current_model: Any, call_kwargs: dict) -> bool:
|
||||
return (
|
||||
bcg_utils.transformer_class_name_matches(current_model, "minimaxh3")
|
||||
and "prompt_embeds" in call_kwargs
|
||||
and "packed_seq_params" in call_kwargs
|
||||
and "refiner_packed_seq_params" in call_kwargs
|
||||
and "text_pos_info" in call_kwargs
|
||||
)
|
||||
|
||||
|
||||
def _position_ids(info: Any) -> torch.Tensor | None:
|
||||
if isinstance(info, dict):
|
||||
ids = info.get("position_ids")
|
||||
else:
|
||||
ids = getattr(info, "position_ids", None)
|
||||
return ids if torch.is_tensor(ids) else None
|
||||
|
||||
|
||||
def _replace_position_ids(info: Any, ids: torch.Tensor) -> dict[str, Any]:
|
||||
if isinstance(info, dict):
|
||||
return {**info, "position_ids": ids}
|
||||
# H3 currently passes dictionaries. Avoid mutating an unknown request
|
||||
# object if an alternate frontend supplies one.
|
||||
return {"position_ids": ids}
|
||||
|
||||
|
||||
def _replace_psp(
|
||||
psp: Any,
|
||||
*,
|
||||
cu_seqlens_q: torch.Tensor,
|
||||
max_seqlen_q: int,
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(psp, dict):
|
||||
return {
|
||||
**psp,
|
||||
"cu_seqlens_q": cu_seqlens_q,
|
||||
"max_seqlen_q": max_seqlen_q,
|
||||
}
|
||||
return {
|
||||
"cu_seqlens_q": cu_seqlens_q,
|
||||
"max_seqlen_q": max_seqlen_q,
|
||||
}
|
||||
|
||||
|
||||
def _aligned(value: int) -> int:
|
||||
alignment = MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT
|
||||
return (value + alignment - 1) // alignment * alignment
|
||||
|
||||
|
||||
def pad_minimax_h3_prompt_kwargs(
|
||||
call_kwargs: dict, current_model: Any, buckets: tuple[int, ...]
|
||||
) -> dict:
|
||||
prompt = bcg_utils.first_tensor(call_kwargs.get("prompt_embeds"))
|
||||
text_pos = _position_ids(call_kwargs.get("text_pos_info"))
|
||||
img_pos = _position_ids(call_kwargs.get("img_pos_info"))
|
||||
audio_pos = _position_ids(call_kwargs.get("audio_pos_info"))
|
||||
x = call_kwargs.get("x")
|
||||
if (
|
||||
not torch.is_tensor(prompt)
|
||||
or prompt.dim() < 2
|
||||
or not torch.is_tensor(text_pos)
|
||||
or not torch.is_tensor(img_pos)
|
||||
or not torch.is_tensor(audio_pos)
|
||||
or not torch.is_tensor(x)
|
||||
or x.dim() != 3
|
||||
):
|
||||
return call_kwargs
|
||||
|
||||
text_len = int(prompt.shape[0])
|
||||
bucket = bcg_utils.select_text_bucket(text_len, buckets)
|
||||
if bucket is None:
|
||||
return call_kwargs
|
||||
|
||||
# All used H3 rows are disjoint text, image/video, or audio rows. Derive
|
||||
# the used/media lengths from tensor shapes so padding itself does not
|
||||
# perform a GPU-to-host .item() synchronization on every denoising step.
|
||||
media_rows = int(img_pos.numel()) + int(audio_pos.numel())
|
||||
used = text_len + media_rows
|
||||
source_seq = int(x.shape[1])
|
||||
if source_seq < _aligned(used):
|
||||
return call_kwargs
|
||||
|
||||
out = dict(call_kwargs)
|
||||
# request-local row lists have prompt-dependent shapes, so keep them out
|
||||
# of bucketed BCG signatures and rebuild the rows in the eager break
|
||||
out.pop("local_embedding_layout", None)
|
||||
# Request-static H3 denoising normally carries the live refined-text
|
||||
# length as a host integer to avoid a per-step device scalar read. Host
|
||||
# integers are baked into BCG signatures, however, so different prompt
|
||||
# lengths would miss the same text bucket. Make only the BCG-padded copy a
|
||||
# scalar tensor; the eager embedding break reads its updated replay value.
|
||||
refined_len = out.get("refined_prompt_embeds_length")
|
||||
if refined_len is not None and not torch.is_tensor(refined_len):
|
||||
out["refined_prompt_embeds_length"] = torch.tensor(
|
||||
int(refined_len),
|
||||
dtype=torch.int64,
|
||||
device=prompt.device,
|
||||
)
|
||||
if text_len < bucket:
|
||||
out["prompt_embeds"] = bcg_utils.pad_tensor_dim(prompt, dim=0, target=bucket)
|
||||
# These rows exist only to stabilize the BCG input signature. The
|
||||
# model's eager embedding break trims prompt/text_pos/refiner metadata
|
||||
# back to ``text_len`` before any projection or attention, so their
|
||||
# values never enter the model.
|
||||
dummy_text_pos = torch.arange(
|
||||
used,
|
||||
used + (bucket - text_len),
|
||||
dtype=text_pos.dtype,
|
||||
device=text_pos.device,
|
||||
)
|
||||
out["text_pos_info"] = _replace_position_ids(
|
||||
out["text_pos_info"], torch.cat((text_pos.view(-1), dummy_text_pos))
|
||||
)
|
||||
|
||||
# Do not grow the main packed sequence to media_rows + bucket. Changing
|
||||
# the SP row partition changes GEMM shapes and is measurably non-bitwise
|
||||
# even though dummy rows live in an independent attention segment. A
|
||||
# capture is therefore reusable only inside the request's existing
|
||||
# 64-row packed-sequence alignment group; other groups safely miss the
|
||||
# signature and run eager.
|
||||
packed_cu = torch.tensor([0, used, source_seq], dtype=torch.int32, device=x.device)
|
||||
out["packed_seq_params"] = _replace_psp(
|
||||
out["packed_seq_params"],
|
||||
cu_seqlens_q=packed_cu,
|
||||
# FA accepts an upper bound; keeping this bucket-stable is required
|
||||
# because non-tensor values are baked into the BCG signature.
|
||||
max_seqlen_q=source_seq,
|
||||
)
|
||||
refiner_cu = torch.tensor(
|
||||
[0, text_len, bucket],
|
||||
dtype=torch.int32,
|
||||
device=prompt.device,
|
||||
)
|
||||
out["refiner_packed_seq_params"] = _replace_psp(
|
||||
out["refiner_packed_seq_params"],
|
||||
cu_seqlens_q=refiner_cu,
|
||||
max_seqlen_q=bucket,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
bcg_utils.register_prompt_padder(
|
||||
is_minimax_h3_transformer, pad_minimax_h3_prompt_kwargs
|
||||
)
|
||||
@@ -301,6 +301,7 @@ def _ensure_model_padders_registered() -> None:
|
||||
_model_padders_registered = True
|
||||
from sglang.multimodal_gen.runtime.breakable_cuda_graph.model_padders import ( # noqa: F401
|
||||
ideogram,
|
||||
minimax_h3,
|
||||
qwen_image,
|
||||
zimage,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,20 @@ from sglang.multimodal_gen.runtime.distributed.parallel_state import get_dit_gro
|
||||
_original_similarity = None
|
||||
|
||||
|
||||
def disable_cache_on_transformer(transformer: torch.nn.Module) -> torch.nn.Module:
|
||||
"""Remove Cache-DiT hooks so subsequent requests use the native forward."""
|
||||
|
||||
logger.info("Disabling cache-dit on %s", type(transformer).__name__)
|
||||
target = getattr(transformer, "_sglang_cache_dit_adapter", transformer)
|
||||
cache_dit.disable_cache(target)
|
||||
if target is not transformer:
|
||||
del transformer._sglang_cache_dit_adapter
|
||||
for name in ("_is_parallelized", "_parallelism_config"):
|
||||
if hasattr(transformer, name):
|
||||
delattr(transformer, name)
|
||||
return transformer
|
||||
|
||||
|
||||
def _patch_cache_dit_similarity():
|
||||
from cache_dit.caching.cache_contexts import cache_manager
|
||||
|
||||
@@ -268,6 +282,7 @@ DUAL_TRANSFORMER_BLOCK_ADAPTER_SPECS: dict[str, DualTransformerBlockAdapterSpec]
|
||||
_CUSTOM_BLOCK_ADAPTER_SPECS: dict[str, tuple[str, ForwardPattern]] = {
|
||||
"ErnieImageTransformer2DModel": ("layers", ForwardPattern.Pattern_3),
|
||||
"Krea2Transformer2DModel": ("transformer_blocks", ForwardPattern.Pattern_3),
|
||||
"MiniMaxH3DiTModel": ("blocks", ForwardPattern.Pattern_3),
|
||||
}
|
||||
|
||||
|
||||
@@ -412,6 +427,8 @@ def enable_cache_on_transformer(
|
||||
calibrator_config=calibrator_config,
|
||||
parallelism_config=None,
|
||||
)
|
||||
if custom_adapter is not None:
|
||||
transformer._sglang_cache_dit_adapter = custom_adapter
|
||||
|
||||
if parallelism_config is not None:
|
||||
context_manager = getattr(transformer, "_context_manager", None)
|
||||
|
||||
@@ -257,8 +257,17 @@ IPC_A2A = IpcA2AState()
|
||||
def ipc_a2a_ready(group) -> bool:
|
||||
"""True when the IPC transport is enabled and initialized (initializes
|
||||
lazily on the first eager call; never inside a graph capture)."""
|
||||
from sglang.multimodal_gen.runtime.distributed import get_tp_world_size
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
|
||||
if not envs.SGLANG_DIFFUSION_IPC_A2A or IPC_A2A.failed:
|
||||
return False
|
||||
# TP+Ulysses groups are strided in global-rank order, while this transport
|
||||
# supports the adjacent two-device topology used by TP1+U2. Reject the
|
||||
# transport consistently before lazy initialization so no rank enters IPC
|
||||
# while its peer falls back to NCCL.
|
||||
if not current_platform.is_cuda() or get_tp_world_size() > 1:
|
||||
return False
|
||||
if IPC_A2A.inited:
|
||||
return True
|
||||
if torch.cuda.is_current_stream_capturing():
|
||||
|
||||
@@ -220,6 +220,7 @@ class DiffGenerator:
|
||||
)
|
||||
|
||||
request_groups: list[list[Req]] = []
|
||||
parent_requests: list[tuple[Req, int]] = []
|
||||
image_paths_per_prompt = self._resolve_image_paths_per_prompt(
|
||||
prompts, sampling_params_orig.image_path
|
||||
)
|
||||
@@ -243,13 +244,32 @@ class DiffGenerator:
|
||||
sampling_params=sampling_params,
|
||||
external_trace_header=external_trace_header,
|
||||
)
|
||||
request_groups.append(
|
||||
expand_request_outputs(
|
||||
req,
|
||||
num_prompts=len(prompts),
|
||||
prompt_index=i,
|
||||
parent_requests.append((req, i))
|
||||
|
||||
for req, prompt_index in parent_requests:
|
||||
sampling_params = req.sampling_params
|
||||
try:
|
||||
if sampling_params.data_type == DataType.VIDEO:
|
||||
sampling_params.prepare_video_request_for_queue(req)
|
||||
request_groups.append(
|
||||
expand_request_outputs(
|
||||
req,
|
||||
num_prompts=len(prompts),
|
||||
prompt_index=prompt_index,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
if sampling_params.data_type == DataType.VIDEO:
|
||||
sampling_params.cleanup_video_request(req)
|
||||
for prepared_requests in request_groups:
|
||||
if (
|
||||
prepared_requests
|
||||
and prepared_requests[0].data_type == DataType.VIDEO
|
||||
):
|
||||
prepared_requests[0].sampling_params.cleanup_video_request(
|
||||
prepared_requests[0]
|
||||
)
|
||||
raise
|
||||
|
||||
results: list[GenerationResult] = []
|
||||
total_start_time = time.perf_counter()
|
||||
@@ -285,6 +305,10 @@ class DiffGenerator:
|
||||
)
|
||||
for idx, path in enumerate(output_file_paths):
|
||||
req = requests[idx]
|
||||
if req.data_type == DataType.VIDEO:
|
||||
req.sampling_params.validate_video_final_outputs(
|
||||
[path], req
|
||||
)
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
@@ -346,6 +370,11 @@ class DiffGenerator:
|
||||
|
||||
for idx in range(len(samples_out)):
|
||||
req = requests[idx]
|
||||
output_file_path = req.output_file_path(1, 0)
|
||||
if req.data_type == DataType.VIDEO and req.save_output:
|
||||
req.sampling_params.validate_video_final_outputs(
|
||||
[output_file_path], req
|
||||
)
|
||||
results.append(
|
||||
GenerationResult(
|
||||
**self._result_common(
|
||||
@@ -355,12 +384,23 @@ class DiffGenerator:
|
||||
frames=frames_out[idx],
|
||||
audio=audios_out[idx],
|
||||
prompt_index=global_output_index + idx,
|
||||
output_file_path=req.output_file_path(1, 0),
|
||||
output_file_path=output_file_path,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Generation failed: %s", e, exc_info=True)
|
||||
finally:
|
||||
if requests and requests[0].data_type == DataType.VIDEO:
|
||||
try:
|
||||
# Pre-queue resources are shared by the shallow
|
||||
# per-output Req copies, so one idempotent cleanup is
|
||||
# sufficient for the whole parent request.
|
||||
requests[0].sampling_params.cleanup_video_request(requests[0])
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to clean up model-owned video request resources",
|
||||
exc_info=True,
|
||||
)
|
||||
global_output_index += len(requests)
|
||||
|
||||
total_gen_time = time.perf_counter() - total_start_time
|
||||
|
||||
@@ -134,6 +134,9 @@ class VideoGenerationsRequest(BaseModel):
|
||||
diffusers_kwargs: Optional[Dict[str, Any]] = None # kwargs for diffusers backend
|
||||
# Performance profiling
|
||||
perf_dump_path: Optional[str] = None
|
||||
profile: Optional[bool] = False
|
||||
num_profiled_timesteps: Optional[int] = None
|
||||
profile_all_stages: Optional[bool] = False
|
||||
|
||||
|
||||
class VideoListResponse(BaseModel):
|
||||
|
||||
@@ -341,10 +341,14 @@ async def _save_url_image_to_path(image_url: str, target_path: str) -> str:
|
||||
async def process_generation_batch(
|
||||
scheduler_client: AsyncSchedulerClient,
|
||||
batch,
|
||||
*,
|
||||
scheduler_batches=None,
|
||||
) -> tuple[list[str], OutputBatch]:
|
||||
total_start_time = time.perf_counter()
|
||||
with trace_req(batch.trace_ctx), log_generation_timer(logger, batch.prompt):
|
||||
result = await scheduler_client.forward([batch])
|
||||
result = await scheduler_client.forward(
|
||||
scheduler_batches if scheduler_batches is not None else [batch]
|
||||
)
|
||||
|
||||
if (
|
||||
result.output is None
|
||||
|
||||
@@ -83,6 +83,120 @@ def _parse_form_extra_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
_MULTIPART_EXTRA_FORM_FIELDS = (
|
||||
"use_duration_template",
|
||||
"use_resolution_template",
|
||||
"use_system_prompt",
|
||||
"use_guardrails",
|
||||
"guardrails",
|
||||
"video_path",
|
||||
"video_url",
|
||||
"generate_sound",
|
||||
"sound_duration",
|
||||
"condition_frame_indexes",
|
||||
"action_mode",
|
||||
"domain_id",
|
||||
"domain_name",
|
||||
"raw_action_dim",
|
||||
"action_fps",
|
||||
"action",
|
||||
"action_view_point",
|
||||
"action_normalization",
|
||||
"condition_frame_indexes_vision",
|
||||
"condition_video_keep",
|
||||
)
|
||||
|
||||
|
||||
def _video_sampling_params_cls(server_args) -> type[SamplingParams]:
|
||||
"""Resolve the params type selected for the current server."""
|
||||
|
||||
sampling_params_cls = SamplingParams
|
||||
if server_args.pipeline_class_name:
|
||||
from sglang.multimodal_gen.registry import get_pipeline_config_classes
|
||||
|
||||
config_classes = get_pipeline_config_classes(server_args.pipeline_class_name)
|
||||
if config_classes is not None:
|
||||
_, sampling_params_cls = config_classes
|
||||
if sampling_params_cls is SamplingParams:
|
||||
from sglang.multimodal_gen.registry import get_model_info
|
||||
|
||||
model_info = get_model_info(
|
||||
server_args.model_path,
|
||||
backend=server_args.backend,
|
||||
model_id=server_args.model_id,
|
||||
)
|
||||
if model_info is not None:
|
||||
sampling_params_cls = model_info.sampling_param_cls
|
||||
return sampling_params_cls
|
||||
|
||||
|
||||
def _multipart_extra_form_keys(
|
||||
sampling_params_cls: type[SamplingParams],
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
(
|
||||
*VideoGenerationsRequest.model_fields,
|
||||
*_MULTIPART_EXTRA_FORM_FIELDS,
|
||||
*sorted(sampling_params_cls.video_request_extra_fields()),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _filter_multipart_declared_fields(
|
||||
extra_from_form: Dict[str, Any],
|
||||
sampling_params_cls: type[SamplingParams],
|
||||
) -> Dict[str, Any]:
|
||||
declared = set(_multipart_extra_form_keys(sampling_params_cls))
|
||||
return {key: value for key, value in extra_from_form.items() if key in declared}
|
||||
|
||||
|
||||
def _merge_multipart_extra_form_fields(
|
||||
raw_form: Any,
|
||||
extra_from_form: Dict[str, Any],
|
||||
sampling_params_cls: type[SamplingParams],
|
||||
) -> None:
|
||||
for key in _multipart_extra_form_keys(sampling_params_cls):
|
||||
if key in raw_form and key not in extra_from_form:
|
||||
extra_from_form[key] = _parse_form_extra_value(raw_form[key])
|
||||
|
||||
|
||||
def _multipart_video_extras(
|
||||
raw_form: Any,
|
||||
*,
|
||||
extra_body: Any,
|
||||
extra_params: Any,
|
||||
sampling_params_cls: type[SamplingParams],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build and validate multipart extras once for request construction."""
|
||||
|
||||
extra_from_form: Dict[str, Any] = {}
|
||||
if extra_body:
|
||||
try:
|
||||
extra_from_form = flatten_extra_params(json.loads(extra_body))
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="extra_body is not valid JSON"
|
||||
) from exc
|
||||
if extra_params:
|
||||
try:
|
||||
extra_from_form.update(
|
||||
flatten_extra_params({"extra_params": json.loads(extra_params)})
|
||||
)
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="extra_params is not valid JSON"
|
||||
) from exc
|
||||
_merge_multipart_extra_form_fields(
|
||||
raw_form,
|
||||
extra_from_form,
|
||||
sampling_params_cls,
|
||||
)
|
||||
flatten_extra_params(extra_from_form)
|
||||
return _filter_multipart_declared_fields(extra_from_form, sampling_params_cls)
|
||||
|
||||
|
||||
def _is_probably_video_source(source: Any) -> bool:
|
||||
content_type = (getattr(source, "content_type", "") or "").lower()
|
||||
if content_type.startswith("video/"):
|
||||
@@ -224,45 +338,52 @@ def _build_video_sampling_params(request_id: str, request: VideoGenerationsReque
|
||||
server_args.pipeline_config.action_stats_path
|
||||
)
|
||||
|
||||
return build_sampling_params(
|
||||
request_id,
|
||||
prompt=request.prompt,
|
||||
num_outputs_per_prompt=max(1, min(int(num_outputs), 10)),
|
||||
size=request.size,
|
||||
width=request.width,
|
||||
height=request.height,
|
||||
num_frames=num_frames,
|
||||
fps=fps,
|
||||
image_path=image_path,
|
||||
video_path=video_path,
|
||||
output_file_name=request_id,
|
||||
seed=request.seed,
|
||||
generator_device=request.generator_device,
|
||||
num_inference_steps=request.num_inference_steps,
|
||||
guidance_scale=request.guidance_scale,
|
||||
guidance_scale_2=request.guidance_scale_2,
|
||||
negative_prompt=request.negative_prompt,
|
||||
max_sequence_length=request.max_sequence_length,
|
||||
flow_shift=request.flow_shift,
|
||||
use_duration_template=_extra_value(request, "use_duration_template"),
|
||||
use_resolution_template=_extra_value(request, "use_resolution_template"),
|
||||
use_system_prompt=_extra_value(request, "use_system_prompt"),
|
||||
use_guardrails=_extra_value(request, "use_guardrails"),
|
||||
enable_teacache=request.enable_teacache,
|
||||
enable_frame_interpolation=request.enable_frame_interpolation,
|
||||
frame_interpolation_exp=request.frame_interpolation_exp,
|
||||
frame_interpolation_scale=request.frame_interpolation_scale,
|
||||
frame_interpolation_model_path=request.frame_interpolation_model_path,
|
||||
enable_upscaling=request.enable_upscaling,
|
||||
upscaling_model_path=request.upscaling_model_path,
|
||||
upscaling_scale=request.upscaling_scale,
|
||||
output_path=request.output_path,
|
||||
output_compression=request.output_compression,
|
||||
output_quality=request.output_quality,
|
||||
perf_dump_path=request.perf_dump_path,
|
||||
diffusers_kwargs=request.diffusers_kwargs,
|
||||
kwargs = {
|
||||
"prompt": request.prompt,
|
||||
"num_outputs_per_prompt": max(1, min(int(num_outputs), 10)),
|
||||
"size": request.size,
|
||||
"width": request.width,
|
||||
"height": request.height,
|
||||
"num_frames": num_frames,
|
||||
"fps": fps,
|
||||
"image_path": image_path,
|
||||
"video_path": video_path,
|
||||
"output_file_name": request_id,
|
||||
"seed": request.seed,
|
||||
"generator_device": request.generator_device,
|
||||
"num_inference_steps": request.num_inference_steps,
|
||||
"guidance_scale": request.guidance_scale,
|
||||
"guidance_scale_2": request.guidance_scale_2,
|
||||
"true_cfg_scale": request.true_cfg_scale,
|
||||
"negative_prompt": request.negative_prompt,
|
||||
"max_sequence_length": request.max_sequence_length,
|
||||
"flow_shift": request.flow_shift,
|
||||
"use_duration_template": _extra_value(request, "use_duration_template"),
|
||||
"use_resolution_template": _extra_value(request, "use_resolution_template"),
|
||||
"use_system_prompt": _extra_value(request, "use_system_prompt"),
|
||||
"use_guardrails": _extra_value(request, "use_guardrails"),
|
||||
"enable_teacache": request.enable_teacache,
|
||||
"enable_frame_interpolation": request.enable_frame_interpolation,
|
||||
"frame_interpolation_exp": request.frame_interpolation_exp,
|
||||
"frame_interpolation_scale": request.frame_interpolation_scale,
|
||||
"frame_interpolation_model_path": request.frame_interpolation_model_path,
|
||||
"enable_upscaling": request.enable_upscaling,
|
||||
"upscaling_model_path": request.upscaling_model_path,
|
||||
"upscaling_scale": request.upscaling_scale,
|
||||
"output_path": request.output_path,
|
||||
"output_compression": request.output_compression,
|
||||
"output_quality": request.output_quality,
|
||||
"perf_dump_path": request.perf_dump_path,
|
||||
"profile": request.profile,
|
||||
"num_profiled_timesteps": request.num_profiled_timesteps,
|
||||
"profile_all_stages": request.profile_all_stages,
|
||||
"diffusers_kwargs": request.diffusers_kwargs,
|
||||
**cosmos3_kwargs,
|
||||
)
|
||||
}
|
||||
|
||||
sampling_params_cls = _video_sampling_params_cls(server_args)
|
||||
kwargs = sampling_params_cls.lower_video_request_kwargs(request, kwargs)
|
||||
return build_sampling_params(request_id, **kwargs)
|
||||
|
||||
|
||||
# extract metadata which http_server needs to know
|
||||
@@ -311,6 +432,7 @@ async def _dispatch_job_async(
|
||||
job_id: str,
|
||||
batch: Req,
|
||||
*,
|
||||
scheduler_batches: list[Req] | None = None,
|
||||
temp_dirs: list[str] | None = None,
|
||||
output_persistent: bool = True,
|
||||
) -> None:
|
||||
@@ -318,9 +440,30 @@ async def _dispatch_job_async(
|
||||
|
||||
try:
|
||||
save_file_path_list, result = await process_generation_batch(
|
||||
async_scheduler_client, batch
|
||||
async_scheduler_client,
|
||||
batch,
|
||||
scheduler_batches=scheduler_batches,
|
||||
)
|
||||
save_file_path = save_file_path_list[0]
|
||||
try:
|
||||
final_media_fields = await asyncio.to_thread(
|
||||
batch.sampling_params.validate_video_final_outputs,
|
||||
save_file_path_list,
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
for output_path in save_file_path_list:
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Failed to remove rejected video output %s",
|
||||
output_path,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
||||
|
||||
@@ -343,13 +486,32 @@ async def _dispatch_job_async(
|
||||
update_fields = add_common_data_to_response(
|
||||
update_fields, request_id=job_id, result=result
|
||||
)
|
||||
update_fields.update(final_media_fields)
|
||||
await VIDEO_STORE.update_fields(job_id, update_fields)
|
||||
except Exception as e:
|
||||
logger.error(f"{e}")
|
||||
await VIDEO_STORE.update_fields(
|
||||
job_id, {"status": "failed", "error": {"message": str(e)}}
|
||||
job_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"error": {"message": str(e)},
|
||||
"url": None,
|
||||
"file_path": None,
|
||||
"file_paths": None,
|
||||
"num_outputs": None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
batch.sampling_params.cleanup_video_request,
|
||||
batch,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to clean up model-owned video request resources",
|
||||
exc_info=True,
|
||||
)
|
||||
for td in temp_dirs or []:
|
||||
shutil.rmtree(td, ignore_errors=True)
|
||||
|
||||
@@ -376,6 +538,8 @@ async def create_video(
|
||||
generator_device: Optional[str] = Form("cuda"),
|
||||
negative_prompt: Optional[str] = Form(None),
|
||||
guidance_scale: Optional[float] = Form(None),
|
||||
guidance_scale_2: Optional[float] = Form(None),
|
||||
true_cfg_scale: Optional[float] = Form(None),
|
||||
num_inference_steps: Optional[int] = Form(None),
|
||||
max_sequence_length: Optional[int] = Form(None),
|
||||
flow_shift: Optional[float] = Form(None),
|
||||
@@ -398,6 +562,22 @@ async def create_video(
|
||||
|
||||
server_args = get_global_server_args()
|
||||
task_type = server_args.pipeline_config.task_type
|
||||
is_multipart = "multipart/form-data" in content_type
|
||||
raw_form: Any = None
|
||||
extra_from_form: Dict[str, Any] = {}
|
||||
|
||||
# Parse model-specific multipart metadata before creating request-owned
|
||||
# directories or saving uploads, so malformed JSON leaves no resources.
|
||||
if is_multipart:
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
raw_form = await request.form()
|
||||
extra_from_form = _multipart_video_extras(
|
||||
raw_form,
|
||||
extra_body=extra_body,
|
||||
extra_params=extra_params,
|
||||
sampling_params_cls=_video_sampling_params_cls(server_args),
|
||||
)
|
||||
|
||||
# Resolve input upload directory (may be a temp dir when saving is disabled)
|
||||
temp_dirs: list[str] = []
|
||||
@@ -411,14 +591,11 @@ async def create_video(
|
||||
# Resolve output directory
|
||||
effective_output_path = server_args.output_path
|
||||
output_persistent = True
|
||||
if "multipart/form-data" not in content_type:
|
||||
if not is_multipart:
|
||||
# JSON body may carry a per-request output_path; checked after parsing below
|
||||
pass
|
||||
|
||||
if "multipart/form-data" in content_type:
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail="prompt is required")
|
||||
|
||||
if is_multipart:
|
||||
video_input_path = None
|
||||
image_sources = merge_image_input_list(input_reference, reference_url)
|
||||
if video_reference is not None:
|
||||
@@ -462,52 +639,10 @@ async def create_video(
|
||||
status_code=400, detail=f"Failed to process image source: {str(e)}"
|
||||
)
|
||||
|
||||
# Parse extra_body JSON (if provided in multipart form) to get fps/num_frames overrides
|
||||
extra_from_form: Dict[str, Any] = {}
|
||||
if extra_body:
|
||||
try:
|
||||
extra_from_form = flatten_extra_params(json.loads(extra_body))
|
||||
except Exception:
|
||||
extra_from_form = {}
|
||||
if extra_params:
|
||||
try:
|
||||
extra_from_form.update(
|
||||
flatten_extra_params({"extra_params": json.loads(extra_params)})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def form_value(name: str, value: Any) -> Any:
|
||||
selected = value if value is not None else extra_from_form.get(name)
|
||||
return _parse_form_extra_value(selected)
|
||||
|
||||
raw_form = await request.form()
|
||||
for key in (
|
||||
"use_duration_template",
|
||||
"use_resolution_template",
|
||||
"use_system_prompt",
|
||||
"use_guardrails",
|
||||
"guardrails",
|
||||
"video_path",
|
||||
"video_url",
|
||||
"generate_sound",
|
||||
"sound_duration",
|
||||
"condition_frame_indexes",
|
||||
"action_mode",
|
||||
"domain_id",
|
||||
"domain_name",
|
||||
"raw_action_dim",
|
||||
"action_fps",
|
||||
"action",
|
||||
"action_view_point",
|
||||
"action_normalization",
|
||||
"condition_frame_indexes_vision",
|
||||
"condition_video_keep",
|
||||
):
|
||||
if key in raw_form and key not in extra_from_form:
|
||||
extra_from_form[key] = _parse_form_extra_value(raw_form[key])
|
||||
flatten_extra_params(extra_from_form)
|
||||
|
||||
request_field_names = set(VideoGenerationsRequest.model_fields)
|
||||
extra_request_fields = {
|
||||
key: value
|
||||
@@ -536,6 +671,8 @@ async def create_video(
|
||||
negative_prompt=form_value("negative_prompt", negative_prompt),
|
||||
num_inference_steps=form_value("num_inference_steps", num_inference_steps),
|
||||
guidance_scale=form_value("guidance_scale", guidance_scale),
|
||||
guidance_scale_2=form_value("guidance_scale_2", guidance_scale_2),
|
||||
true_cfg_scale=form_value("true_cfg_scale", true_cfg_scale),
|
||||
max_sequence_length=form_value("max_sequence_length", max_sequence_length),
|
||||
flow_shift=form_value("flow_shift", flow_shift),
|
||||
enable_teacache=form_value("enable_teacache", enable_teacache),
|
||||
@@ -639,30 +776,59 @@ async def create_video(
|
||||
try:
|
||||
sampling_params = _build_video_sampling_params(request_id, req)
|
||||
except (ValueError, TypeError) as e:
|
||||
for td in temp_dirs:
|
||||
shutil.rmtree(td, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
job = _video_job_from_sampling(request_id, req, sampling_params)
|
||||
await VIDEO_STORE.upsert(request_id, job)
|
||||
batch: Req | None = None
|
||||
scheduler_batches: list[Req] | None = None
|
||||
try:
|
||||
# Build Req for scheduler.
|
||||
trace_headers = extract_trace_headers(request.headers)
|
||||
batch = prepare_request(
|
||||
server_args=server_args,
|
||||
sampling_params=sampling_params,
|
||||
external_trace_header=trace_headers,
|
||||
)
|
||||
# Add diffusers_kwargs if provided.
|
||||
if req.diffusers_kwargs:
|
||||
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
|
||||
if "max_sequence_length" in req.diffusers_kwargs:
|
||||
batch.max_sequence_length = req.diffusers_kwargs["max_sequence_length"]
|
||||
if "flow_shift" in req.diffusers_kwargs:
|
||||
batch.flow_shift = req.diffusers_kwargs["flow_shift"]
|
||||
await asyncio.to_thread(
|
||||
sampling_params.prepare_video_request_for_queue,
|
||||
batch,
|
||||
)
|
||||
scheduler_batches = sampling_params.expand_video_request_outputs_for_queue(
|
||||
batch
|
||||
)
|
||||
job = _video_job_from_sampling(request_id, req, sampling_params)
|
||||
job.update(sampling_params.project_video_queued_job_fields(batch))
|
||||
await VIDEO_STORE.upsert(request_id, job)
|
||||
except Exception as e:
|
||||
if batch is not None:
|
||||
try:
|
||||
await asyncio.to_thread(sampling_params.cleanup_video_request, batch)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to clean up rejected video request resources",
|
||||
exc_info=True,
|
||||
)
|
||||
for td in temp_dirs:
|
||||
shutil.rmtree(td, ignore_errors=True)
|
||||
if isinstance(e, (TypeError, ValueError)):
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
raise
|
||||
|
||||
# Build Req for scheduler
|
||||
trace_headers = extract_trace_headers(request.headers)
|
||||
batch = prepare_request(
|
||||
server_args=server_args,
|
||||
sampling_params=sampling_params,
|
||||
external_trace_header=trace_headers,
|
||||
)
|
||||
# Add diffusers_kwargs if provided
|
||||
if req.diffusers_kwargs:
|
||||
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
|
||||
if "max_sequence_length" in req.diffusers_kwargs:
|
||||
batch.max_sequence_length = req.diffusers_kwargs["max_sequence_length"]
|
||||
if "flow_shift" in req.diffusers_kwargs:
|
||||
batch.flow_shift = req.diffusers_kwargs["flow_shift"]
|
||||
assert batch is not None
|
||||
# Enqueue the job asynchronously and return immediately
|
||||
asyncio.create_task(
|
||||
_dispatch_job_async(
|
||||
request_id,
|
||||
batch,
|
||||
scheduler_batches=scheduler_batches,
|
||||
temp_dirs=temp_dirs or None,
|
||||
output_persistent=output_persistent,
|
||||
)
|
||||
@@ -717,6 +883,21 @@ async def delete_video(video_id: str = Path(...)):
|
||||
return VideoResponse(**job)
|
||||
|
||||
|
||||
def _select_video_variant_path(job: dict, variant: str | None) -> str | None:
|
||||
file_paths = job.get("file_paths")
|
||||
if file_paths:
|
||||
try:
|
||||
variant_index = 0 if variant is None else int(variant)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if 0 <= variant_index < len(file_paths):
|
||||
return file_paths[variant_index]
|
||||
return None
|
||||
if variant not in (None, "0", 0):
|
||||
return None
|
||||
return job.get("file_path")
|
||||
|
||||
|
||||
@router.get("/{video_id}/content")
|
||||
async def download_video_content(
|
||||
video_id: str = Path(...), variant: Optional[str] = Query(None)
|
||||
@@ -731,9 +912,13 @@ async def download_video_content(
|
||||
detail=f"Video has been uploaded to cloud storage. Please use the cloud URL: {job.get('url')}",
|
||||
)
|
||||
|
||||
file_path = job.get("file_path")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
file_path = _select_video_variant_path(job, variant)
|
||||
if job.get("status") not in {"completed", "failed"}:
|
||||
raise HTTPException(status_code=404, detail="Generation is still in-progress")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Video variant {variant} not found"
|
||||
)
|
||||
|
||||
media_type = "video/mp4" # default variant
|
||||
return FileResponse(
|
||||
|
||||
@@ -341,6 +341,7 @@ def expand_request_outputs(
|
||||
req.seed = seeds[0]
|
||||
req.seeds = None
|
||||
req.generator = None
|
||||
req.sampling_params.refresh_request_extra_after_output_expansion(req)
|
||||
return [req]
|
||||
|
||||
expanded: list[Req] = []
|
||||
@@ -365,6 +366,9 @@ def expand_request_outputs(
|
||||
output_req.output_file_name = _with_output_index_suffix(
|
||||
req.output_file_name, output_index
|
||||
)
|
||||
output_req.sampling_params.refresh_request_extra_after_output_expansion(
|
||||
output_req
|
||||
)
|
||||
output_req.validate()
|
||||
expanded.append(output_req)
|
||||
|
||||
@@ -487,7 +491,7 @@ def _try_save_cuda_video_direct(
|
||||
if video.shape[0] != 3:
|
||||
return False
|
||||
|
||||
frames = (video * 255).clamp(0, 255).to(torch.uint8)
|
||||
frames = (video * 255).clamp_(0, 255).to(torch.uint8)
|
||||
frames = frames.permute(1, 2, 3, 0).contiguous()
|
||||
num_frames, height, width, _ = frames.shape
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -15,7 +16,10 @@ from sglang.multimodal_gen.runtime.layers.attention.backends.attention_backend i
|
||||
AttentionMetadataBuilder,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.platforms import AttentionBackendEnum
|
||||
from sglang.multimodal_gen.runtime.platforms.aiter import USE_AITER_GFX95
|
||||
from sglang.multimodal_gen.runtime.platforms.aiter import (
|
||||
USE_AITER_GFX95,
|
||||
USE_AITER_GFX942,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -205,3 +209,38 @@ class AITerImpl(AttentionImpl):
|
||||
return_lse=True,
|
||||
)
|
||||
return output
|
||||
|
||||
@torch.compiler.disable
|
||||
def forward_varlen(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens: torch.Tensor,
|
||||
max_seqlen: int,
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
del cu_seqlens_host
|
||||
if USE_AITER_GFX942:
|
||||
# The grouped-varlen ASM kernel hangs on H3's ~64K packed
|
||||
# sequences on gfx942; AITER's Triton path handles this shape.
|
||||
attention_func = importlib.import_module(
|
||||
"aiter.ops.triton.attention.mha"
|
||||
).flash_attn_varlen_func
|
||||
else:
|
||||
attention_func = aiter.flash_attn_varlen_func
|
||||
|
||||
cu_seqlens = cu_seqlens.to(device=query.device, dtype=torch.int32).contiguous()
|
||||
output = attention_func(
|
||||
q=query.contiguous(),
|
||||
k=key.contiguous(),
|
||||
v=value.contiguous(),
|
||||
cu_seqlens_q=cu_seqlens,
|
||||
cu_seqlens_k=cu_seqlens,
|
||||
max_seqlen_q=max_seqlen,
|
||||
max_seqlen_k=max_seqlen,
|
||||
softmax_scale=self.softmax_scale,
|
||||
causal=self.causal,
|
||||
)
|
||||
return output[0] if isinstance(output, tuple) else output
|
||||
|
||||
@@ -170,6 +170,20 @@ class AttentionImpl(ABC, Generic[T]):
|
||||
) -> torch.Tensor:
|
||||
raise NotImplementedError
|
||||
|
||||
def forward_varlen(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens: torch.Tensor,
|
||||
max_seqlen: int,
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not implement packed varlen attention"
|
||||
)
|
||||
|
||||
|
||||
def wrap_attention_impl_forward(attn_impl: AttentionImpl) -> AttentionImpl:
|
||||
return wrap_method_with_debug_kernel_once(
|
||||
|
||||
@@ -443,3 +443,28 @@ class FlashAttentionImpl(AttentionImpl):
|
||||
return out_tensor
|
||||
|
||||
raise ValueError(f"flash attention version {fa_ver} is not supported.")
|
||||
|
||||
def forward_varlen(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens: torch.Tensor,
|
||||
max_seqlen: int,
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
del cu_seqlens_host
|
||||
output = flash_attn_varlen_func(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
cu_seqlens_q=cu_seqlens,
|
||||
cu_seqlens_k=cu_seqlens,
|
||||
max_seqlen_q=max_seqlen,
|
||||
max_seqlen_k=max_seqlen,
|
||||
softmax_scale=self.softmax_scale,
|
||||
causal=self.causal,
|
||||
ver=fa_ver,
|
||||
)
|
||||
return output[0] if isinstance(output, tuple) else output
|
||||
|
||||
@@ -94,6 +94,35 @@ class SDPAImpl(AttentionImpl):
|
||||
output = output.transpose(1, 2)
|
||||
return output
|
||||
|
||||
def forward_varlen(
|
||||
self,
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
*,
|
||||
cu_seqlens: torch.Tensor,
|
||||
max_seqlen: int,
|
||||
cu_seqlens_host: tuple[int, ...] | None = None,
|
||||
) -> torch.Tensor:
|
||||
del max_seqlen
|
||||
bounds = (
|
||||
cu_seqlens_host
|
||||
if cu_seqlens_host is not None
|
||||
else tuple(int(item) for item in cu_seqlens.tolist())
|
||||
)
|
||||
output = torch.empty_like(query)
|
||||
for start, stop in zip(bounds[:-1], bounds[1:]):
|
||||
if start == stop:
|
||||
continue
|
||||
segment = self.forward(
|
||||
query[start:stop].unsqueeze(0),
|
||||
key[start:stop].unsqueeze(0),
|
||||
value[start:stop].unsqueeze(0),
|
||||
None,
|
||||
)
|
||||
output[start:stop].copy_(segment[0])
|
||||
return output
|
||||
|
||||
|
||||
class CudnnSDPABackend(SDPABackend):
|
||||
@staticmethod
|
||||
@@ -122,7 +151,7 @@ class DynamicCudnnSDPABackend(SDPABackend):
|
||||
return DynamicCudnnSDPAImpl
|
||||
|
||||
|
||||
class DynamicCudnnSDPAImpl(AttentionImpl):
|
||||
class DynamicCudnnSDPAImpl(SDPAImpl):
|
||||
def __init__(
|
||||
self,
|
||||
num_heads: int,
|
||||
|
||||
@@ -8,6 +8,10 @@ import torch.distributed as dist
|
||||
import torch.distributed._functional_collectives as ft_c
|
||||
from torch.distributed.tensor.experimental._attention import _cp_options
|
||||
|
||||
from sglang.kernels.ops.diffusion.triton.ulysses_qkv import (
|
||||
pack_qkv_destination_major,
|
||||
)
|
||||
from sglang.kernels.ops.diffusion.usp_relayout import usp_merge_heads
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_sp_group,
|
||||
get_ulysses_parallel_rank,
|
||||
@@ -280,6 +284,47 @@ def _usp_input_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
return x
|
||||
|
||||
|
||||
def _usp_input_all_to_all_packed_qkv(
|
||||
q: torch.Tensor,
|
||||
k: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Exchange 3D Q/K/V with one destination-major Ulysses collective."""
|
||||
world_size = get_ulysses_parallel_world_size()
|
||||
if world_size <= 1:
|
||||
return q, k, v
|
||||
|
||||
assert q.ndim == 3 and q.shape == k.shape == v.shape
|
||||
s_local, h_global, head_size = q.shape
|
||||
assert h_global % world_size == 0
|
||||
h_local = h_global // world_size
|
||||
|
||||
if (
|
||||
q.is_cuda
|
||||
and q.dtype in (torch.float16, torch.bfloat16)
|
||||
and q.dtype == k.dtype == v.dtype
|
||||
and q.stride(-1) == k.stride(-1) == v.stride(-1) == 1
|
||||
and not torch.compiler.is_compiling()
|
||||
):
|
||||
packed = pack_qkv_destination_major(q, k, v, world_size)
|
||||
else:
|
||||
packed = torch.empty(
|
||||
(world_size, s_local, h_local, 3 * head_size),
|
||||
dtype=q.dtype,
|
||||
device=q.device,
|
||||
)
|
||||
for index, tensor in enumerate((q, k, v)):
|
||||
head_shards = tensor.view(s_local, world_size, h_local, head_size).permute(
|
||||
1, 0, 2, 3
|
||||
)
|
||||
packed[..., index * head_size : (index + 1) * head_size].copy_(head_shards)
|
||||
|
||||
packed = _usp_all_to_all_single(packed)
|
||||
packed = packed.reshape(s_local * world_size, h_local, 3 * head_size)
|
||||
q, k, v = packed.split(head_size, dim=-1)
|
||||
return q, k, v
|
||||
|
||||
|
||||
def _usp_input_all_to_all_varlen(
|
||||
x: torch.Tensor, seq_lens: list[int], head_dim: int = 1
|
||||
) -> torch.Tensor:
|
||||
@@ -419,7 +464,7 @@ def _usp_output_all_to_all(x: torch.Tensor, head_dim: int = 1) -> torch.Tensor:
|
||||
x = x.permute(2, 0, 3, 1, 4).contiguous().reshape(b, h_global, s_local, d)
|
||||
else: # head_dim == 2
|
||||
# Shape transition: [world_size, s_local, b, h_local, d] -> [b, s_local, world_size, h_local, d]
|
||||
x = x.permute(2, 1, 0, 3, 4).contiguous().reshape(b, s_local, h_global, d)
|
||||
x = usp_merge_heads(x).reshape(b, s_local, h_global, d)
|
||||
|
||||
return x
|
||||
|
||||
|
||||
@@ -113,9 +113,12 @@ class ComponentLoader(ABC):
|
||||
return {}
|
||||
|
||||
def should_raise_customized_load_error(
|
||||
self, _server_args: ServerArgs, _component_name: str
|
||||
self, server_args: ServerArgs, component_name: str
|
||||
) -> bool:
|
||||
return False
|
||||
native_only_components = getattr(
|
||||
server_args.pipeline_config, "native_only_components", ()
|
||||
)
|
||||
return component_name in native_only_components
|
||||
|
||||
@staticmethod
|
||||
def _is_component_set_as_layerwise_load(
|
||||
|
||||
@@ -54,7 +54,6 @@ class ImageEncoderLoader(TextEncoderLoader):
|
||||
finalize_encoder_folding(
|
||||
encoder_config,
|
||||
server_args.encoder_parallel,
|
||||
batched=server_args.batching_max_size > 1,
|
||||
)
|
||||
|
||||
# Always start with local device; load_model will adjust for offload if needed
|
||||
|
||||
+42
-3
@@ -2,7 +2,7 @@ import dataclasses
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Generator, Iterable
|
||||
from collections.abc import Callable, Generator, Iterable
|
||||
from contextlib import nullcontext
|
||||
from typing import cast
|
||||
|
||||
@@ -39,6 +39,7 @@ from sglang.multimodal_gen.runtime.loader.weight_utils import (
|
||||
safetensors_weights_iterator,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.encoders.base import (
|
||||
TextEncoder,
|
||||
finalize_encoder_folding,
|
||||
get_folding_tp_group,
|
||||
)
|
||||
@@ -184,6 +185,7 @@ class TextEncoderLoader(ComponentLoader):
|
||||
model_name_or_path: str,
|
||||
fall_back_to_pt: bool,
|
||||
allow_patterns_overrides: list[str] | None,
|
||||
key_filter: Callable[[str], bool] | None = None,
|
||||
) -> tuple[str, list[str], bool]:
|
||||
"""Prepare weights for the model.
|
||||
|
||||
@@ -216,7 +218,10 @@ class TextEncoderLoader(ComponentLoader):
|
||||
|
||||
if use_safetensors:
|
||||
hf_weights_files = filter_duplicate_safetensors_files(
|
||||
hf_weights_files, hf_folder, index_file
|
||||
hf_weights_files,
|
||||
hf_folder,
|
||||
index_file,
|
||||
key_filter=key_filter,
|
||||
)
|
||||
else:
|
||||
hf_weights_files = filter_files_not_needed_for_inference(hf_weights_files)
|
||||
@@ -237,20 +242,39 @@ class TextEncoderLoader(ComponentLoader):
|
||||
self,
|
||||
source: "Source",
|
||||
to_cpu: bool,
|
||||
key_filter: Callable[[str], bool] | None = None,
|
||||
) -> Generator[tuple[str, torch.Tensor], None, None]:
|
||||
"""get an iterator for the model weights based on the load format."""
|
||||
source_key_filter: Callable[[str], bool] | None
|
||||
if key_filter is None:
|
||||
source_key_filter = None
|
||||
else:
|
||||
|
||||
def include_source_weight(name: str) -> bool:
|
||||
return key_filter(source.prefix + name)
|
||||
|
||||
source_key_filter = include_source_weight
|
||||
|
||||
hf_folder, hf_weights_files, use_safetensors = self._prepare_weights(
|
||||
source.model_or_path,
|
||||
source.fall_back_to_pt,
|
||||
source.allow_patterns_overrides,
|
||||
key_filter=source_key_filter,
|
||||
)
|
||||
if use_safetensors:
|
||||
weights_iterator = safetensors_weights_iterator(
|
||||
hf_weights_files,
|
||||
to_cpu=to_cpu,
|
||||
key_filter=source_key_filter,
|
||||
)
|
||||
else:
|
||||
weights_iterator = pt_weights_iterator(hf_weights_files, to_cpu=to_cpu)
|
||||
if source_key_filter is not None:
|
||||
weights_iterator = (
|
||||
(name, tensor)
|
||||
for name, tensor in weights_iterator
|
||||
if source_key_filter(name)
|
||||
)
|
||||
|
||||
# apply the prefix.
|
||||
return ((source.prefix + name, tensor) for (name, tensor) in weights_iterator)
|
||||
@@ -261,6 +285,10 @@ class TextEncoderLoader(ComponentLoader):
|
||||
model_path: str,
|
||||
to_cpu: bool,
|
||||
) -> Generator[tuple[str, torch.Tensor], None, None]:
|
||||
key_filter = cast(
|
||||
Callable[[str], bool] | None,
|
||||
getattr(model, "should_materialize_checkpoint_weight", None),
|
||||
)
|
||||
primary_weights = TextEncoderLoader.Source(
|
||||
model_path,
|
||||
prefix="",
|
||||
@@ -270,6 +298,7 @@ class TextEncoderLoader(ComponentLoader):
|
||||
yield from self._get_weights_iterator(
|
||||
primary_weights,
|
||||
to_cpu,
|
||||
key_filter,
|
||||
)
|
||||
|
||||
secondary_weights = cast(
|
||||
@@ -280,6 +309,7 @@ class TextEncoderLoader(ComponentLoader):
|
||||
yield from self._get_weights_iterator(
|
||||
source,
|
||||
to_cpu,
|
||||
key_filter,
|
||||
)
|
||||
|
||||
def load_customized(
|
||||
@@ -314,11 +344,20 @@ class TextEncoderLoader(ComponentLoader):
|
||||
)
|
||||
if post_diffusers_config_update is not None:
|
||||
post_diffusers_config_update()
|
||||
model_cls, _ = ModelRegistry.resolve_model_cls(
|
||||
getattr(encoder_config, "architectures", [])
|
||||
)
|
||||
# real dims are populated now; resolve fold vs replicate
|
||||
finalize_encoder_folding(
|
||||
encoder_config,
|
||||
server_args.encoder_parallel,
|
||||
batched=server_args.batching_max_size > 1,
|
||||
prefer_dp=(
|
||||
server_args.batching_max_size > 1
|
||||
and (server_args.tp_size or 1) == 1
|
||||
and (server_args.dp_size or 1) == 1
|
||||
and issubclass(model_cls, TextEncoder)
|
||||
and model_cls.supports_dp_encode
|
||||
),
|
||||
)
|
||||
encoder_dtype = server_args.pipeline_config.text_encoder_precisions[
|
||||
encoder_index
|
||||
|
||||
+17
-11
@@ -28,6 +28,20 @@ _is_npu = is_npu()
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def _warn_if_expected_param_dtype_missing(
|
||||
model: torch.nn.Module, expected_dtype: torch.dtype | None
|
||||
) -> None:
|
||||
if expected_dtype is None:
|
||||
return
|
||||
param_dtypes = {param.dtype for param in model.parameters()}
|
||||
if expected_dtype not in param_dtypes:
|
||||
logger.warning(
|
||||
"Model parameter dtypes do not include expected param dtype, %s vs %s",
|
||||
param_dtypes,
|
||||
expected_dtype,
|
||||
)
|
||||
|
||||
|
||||
def _server_args_for_transformer_component(
|
||||
server_args: ServerArgs, component_name: str
|
||||
) -> ServerArgs:
|
||||
@@ -89,7 +103,8 @@ class TransformerLoader(ComponentLoader):
|
||||
# Don't let a quantized load quietly fall back to the unquantized native
|
||||
# model. That would drop the requested precision and bury the real error.
|
||||
return (
|
||||
component_server_args.transformer_weights_path is not None
|
||||
super().should_raise_customized_load_error(server_args, component_name)
|
||||
or component_server_args.transformer_weights_path is not None
|
||||
or component_server_args.quantization is not None
|
||||
)
|
||||
|
||||
@@ -185,15 +200,6 @@ class TransformerLoader(ComponentLoader):
|
||||
for post_load_hook in quant_spec.post_load_hooks:
|
||||
post_load_hook(model)
|
||||
|
||||
# considering the existent of mixed-precision models (e.g., nunchaku)
|
||||
if (
|
||||
next(model.parameters()).dtype != quant_spec.param_dtype
|
||||
and quant_spec.param_dtype
|
||||
):
|
||||
logger.warning(
|
||||
"Model dtype does not match expected param dtype, %s vs %s",
|
||||
next(model.parameters()).dtype,
|
||||
quant_spec.param_dtype,
|
||||
)
|
||||
_warn_if_expected_param_dtype_missing(model, quant_spec.param_dtype)
|
||||
|
||||
return model
|
||||
|
||||
@@ -142,10 +142,12 @@ class VAELoader(ComponentLoader):
|
||||
should_offload = self.should_offload(server_args)
|
||||
target_device = self.target_device(should_offload)
|
||||
|
||||
# Check for auto_map first (custom VAE classes)
|
||||
native_only = component_name in getattr(
|
||||
server_args.pipeline_config, "native_only_components", ()
|
||||
)
|
||||
auto_map = config.get("auto_map", {})
|
||||
auto_model_map = auto_map.get("AutoModel")
|
||||
if auto_model_map:
|
||||
if auto_model_map and not native_only:
|
||||
module_path, cls_name = auto_model_map.rsplit(".", 1)
|
||||
custom_module_file = os.path.join(component_model_path, f"{module_path}.py")
|
||||
spec = importlib.util.spec_from_file_location("_custom", custom_module_file)
|
||||
@@ -191,16 +193,18 @@ class VAELoader(ComponentLoader):
|
||||
for sf_path in safetensors_list:
|
||||
loaded.update(safetensors_load_file(sf_path))
|
||||
_backfill_ltx2_audio_vae_latent_stats(loaded, component_name)
|
||||
vae.load_state_dict(loaded, strict=False)
|
||||
strict_load = native_only
|
||||
vae.load_state_dict(loaded, strict=strict_load)
|
||||
|
||||
state_keys = set(vae.state_dict().keys())
|
||||
loaded_keys = set(loaded.keys())
|
||||
missing_keys = sorted(state_keys - loaded_keys)
|
||||
unexpected_keys = sorted(loaded_keys - state_keys)
|
||||
if missing_keys:
|
||||
logger.warning("VAE missing keys: %s", missing_keys)
|
||||
if unexpected_keys:
|
||||
logger.warning("VAE unexpected keys: %s", unexpected_keys)
|
||||
if not strict_load:
|
||||
state_keys = set(vae.state_dict().keys())
|
||||
loaded_keys = set(loaded.keys())
|
||||
missing_keys = sorted(state_keys - loaded_keys)
|
||||
unexpected_keys = sorted(loaded_keys - state_keys)
|
||||
if missing_keys:
|
||||
logger.warning("VAE missing keys: %s", missing_keys)
|
||||
if unexpected_keys:
|
||||
logger.warning("VAE unexpected keys: %s", unexpected_keys)
|
||||
|
||||
if _should_use_channels_last_3d(server_args, component_name):
|
||||
n = _convert_conv3d_weights_to_channels_last_3d(vae)
|
||||
|
||||
@@ -20,6 +20,7 @@ from torch.distributed.fsdp import (
|
||||
FSDPModule,
|
||||
MixedPrecisionPolicy,
|
||||
fully_shard,
|
||||
register_fsdp_forward_method,
|
||||
)
|
||||
from torch.nn.modules.module import _IncompatibleKeys
|
||||
|
||||
@@ -204,7 +205,8 @@ def maybe_load_fsdp_model(
|
||||
Args:
|
||||
param_dtype: Data type for model parameters, also used for:
|
||||
- Model initialization context (set_default_torch_dtype)
|
||||
- FSDP mixed precision policy
|
||||
- FSDP mixed precision policy unless the model preserves mixed
|
||||
original parameter dtypes
|
||||
- Weight loading and casting
|
||||
reduce_dtype: Data type for gradient reduction in FSDP mixed precision.
|
||||
strict: If True, enforce strict state dict loading (all keys must match).
|
||||
@@ -215,8 +217,19 @@ def maybe_load_fsdp_model(
|
||||
|
||||
# 1. prepare for loading
|
||||
default_torch_dtype = param_dtype if param_dtype else torch.bfloat16
|
||||
# Some native models deliberately mix FP32 projections with lower-precision
|
||||
# blocks. FSDP must all-gather those parameters in their original dtypes;
|
||||
# the thread-local compute dtype below remains the requested default.
|
||||
fsdp_param_dtype = (
|
||||
None
|
||||
if fsdp_inference and getattr(model_cls, "_fsdp_mixed_dtype_params", False)
|
||||
else default_torch_dtype
|
||||
)
|
||||
mp_policy = MixedPrecisionPolicy(
|
||||
default_torch_dtype, reduce_dtype, output_dtype, cast_forward_inputs=False
|
||||
param_dtype=fsdp_param_dtype,
|
||||
reduce_dtype=reduce_dtype,
|
||||
output_dtype=output_dtype,
|
||||
cast_forward_inputs=False,
|
||||
)
|
||||
|
||||
set_mixed_precision_policy(
|
||||
@@ -279,6 +292,8 @@ def maybe_load_fsdp_model(
|
||||
fsdp_shard_conditions=getattr(model, "_fsdp_shard_conditions", None),
|
||||
pin_cpu_memory=pin_cpu_memory,
|
||||
)
|
||||
if callable(getattr(model, "refine_prompt_embeds", None)):
|
||||
register_fsdp_forward_method(model, "refine_prompt_embeds")
|
||||
|
||||
param_names_mapping_fn = get_param_names_mapping(model.param_names_mapping)
|
||||
|
||||
|
||||
@@ -601,7 +601,12 @@ def _resolve_quant_config(
|
||||
# in source dtype and are quantized in
|
||||
# process_weights_after_loading.
|
||||
quant_cls = get_quantization_config(server_args.quantization)
|
||||
return quant_cls()
|
||||
quant_kwargs = {}
|
||||
if server_args.quantization in {"fp8", "mxfp4"}:
|
||||
quant_kwargs["ignored_layers"] = getattr(
|
||||
server_args, "quantization_ignored_layers", None
|
||||
)
|
||||
return quant_cls(**quant_kwargs)
|
||||
|
||||
quant_config = get_quant_config(hf_config, component_model_path)
|
||||
if quant_config is None and server_args.transformer_weights_path:
|
||||
|
||||
@@ -65,7 +65,10 @@ def get_lock(model_name_or_path: str | Path, cache_dir: str | None = None):
|
||||
# So, we use the index_file to
|
||||
# look up which safetensors files should be used.
|
||||
def filter_duplicate_safetensors_files(
|
||||
hf_weights_files: list[str], hf_folder: str, index_file: str
|
||||
hf_weights_files: list[str],
|
||||
hf_folder: str,
|
||||
index_file: str,
|
||||
key_filter: Callable[[str], bool] | None = None,
|
||||
) -> list[str]:
|
||||
# model.safetensors.index.json is a mapping from keys in the
|
||||
# torch state_dict to safetensors file holding that weight.
|
||||
@@ -79,6 +82,9 @@ def filter_duplicate_safetensors_files(
|
||||
weight_map = json.load(f)["weight_map"]
|
||||
weight_files_in_index = set()
|
||||
for weight_name in weight_map:
|
||||
# remove only shards whose indexed tensors are all filtered
|
||||
if key_filter is not None and not key_filter(weight_name):
|
||||
continue
|
||||
weight_files_in_index.add(os.path.join(hf_folder, weight_map[weight_name]))
|
||||
# Filter out any fields that are not found in the index file.
|
||||
hf_weights_files = [f for f in hf_weights_files if f in weight_files_in_index]
|
||||
|
||||
@@ -107,6 +107,21 @@ class _ExpandedOutputParts:
|
||||
trajectory_decoded_parts: list[list[torch.Tensor]] | None = None
|
||||
|
||||
|
||||
def _worker_cpu_intra_op_threads(num_gpus: int) -> int | None:
|
||||
"""CPU intra-op thread budget for one of `num_gpus` co-located workers.
|
||||
|
||||
torch defaults the intra-op pool to every host core in every worker, so
|
||||
co-located workers oversubscribe the host num_gpus-fold and any CPU op
|
||||
past the ~32k-element parallel grain pays pool wakeup contention instead
|
||||
of microseconds (measured 500x on request-static packed layouts). An
|
||||
explicit OMP_NUM_THREADS keeps deployer intent (returns None).
|
||||
"""
|
||||
if "OMP_NUM_THREADS" in os.environ:
|
||||
return None
|
||||
cpu_count = os.cpu_count() or 1
|
||||
return max(1, min(16, cpu_count // max(1, num_gpus)))
|
||||
|
||||
|
||||
class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
"""
|
||||
A worker that executes the model on a single GPU.
|
||||
@@ -198,6 +213,9 @@ class GPUWorker(GPUWorkerPostTrainingMixin):
|
||||
def init_device_and_model(self) -> None:
|
||||
"""Initialize the device and load the model."""
|
||||
torch.get_device_module().set_device(self.local_rank)
|
||||
intra_op_threads = _worker_cpu_intra_op_threads(self.server_args.num_gpus)
|
||||
if intra_op_threads is not None:
|
||||
torch.set_num_threads(intra_op_threads)
|
||||
# Set environment variables for distributed initialization
|
||||
os.environ["MASTER_ADDR"] = "localhost"
|
||||
os.environ["MASTER_PORT"] = str(self.master_port)
|
||||
|
||||
@@ -67,8 +67,12 @@ class LayerwiseOffloadManager:
|
||||
)
|
||||
self.copy_stream = torch.get_device_module().Stream()
|
||||
|
||||
# ``named_parameters()`` is relative to ``model``, just like the path in
|
||||
# ``layers_attr_str``. Anchor the match so a manager for top-level
|
||||
# ``blocks`` cannot also capture an unrelated nested list such as
|
||||
# ``token_refiner.blocks`` whose forward hooks run at a different time.
|
||||
self._layer_name_re = re.compile(
|
||||
rf"(^|\.){re.escape(layers_attr_str)}\.(\d+)(\.|$)"
|
||||
rf"^{re.escape(layers_attr_str)}\.(?P<layer_idx>\d+)(\.|$)"
|
||||
)
|
||||
|
||||
# layer_idx -> {dtype: consolidated_pinned_cpu_tensor}
|
||||
@@ -99,7 +103,7 @@ class LayerwiseOffloadManager:
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(m.group(2))
|
||||
return int(m.group("layer_idx"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -612,6 +616,10 @@ class LayerwiseOffloadableModuleMixin:
|
||||
self.layerwise_offload_managers = []
|
||||
named_modules = dict(self.named_modules())
|
||||
configured_layer_names = []
|
||||
# These legacy tuning knobs are explicitly DiT-scoped. Auxiliary
|
||||
# components still support layerwise streaming, but their layers run
|
||||
# once per component use and get no reuse benefit from DiT residency.
|
||||
dit_tuning_enabled = self.layerwise_offload_dit_group_enabled
|
||||
for layer_name in self.layer_names:
|
||||
module_list = named_modules.get(layer_name)
|
||||
if not isinstance(module_list, (torch.nn.ModuleList, torch.nn.Sequential)):
|
||||
@@ -620,14 +628,17 @@ class LayerwiseOffloadableModuleMixin:
|
||||
continue
|
||||
|
||||
num_layers = len(module_list)
|
||||
if server_args.dit_offload_prefetch_size < 1.0:
|
||||
prefetch_size = 1 + int(
|
||||
round(server_args.dit_offload_prefetch_size * (num_layers - 1))
|
||||
)
|
||||
prefetch_value = (
|
||||
server_args.dit_offload_prefetch_size if dit_tuning_enabled else 0.0
|
||||
)
|
||||
if prefetch_value < 1.0:
|
||||
prefetch_size = 1 + int(round(prefetch_value * (num_layers - 1)))
|
||||
else:
|
||||
prefetch_size = int(server_args.dit_offload_prefetch_size)
|
||||
prefetch_size = int(prefetch_value)
|
||||
|
||||
resident_value = server_args.dit_layerwise_resident_layers
|
||||
resident_value = (
|
||||
server_args.dit_layerwise_resident_layers if dit_tuning_enabled else 0.0
|
||||
)
|
||||
if resident_value <= 0:
|
||||
resident_layers = 0
|
||||
elif resident_value < 1.0:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,11 +123,11 @@ def encoder_dp_worthwhile(
|
||||
|
||||
|
||||
def finalize_encoder_folding(
|
||||
config: EncoderConfig, policy: str = "auto", batched: bool = False
|
||||
config: EncoderConfig, policy: str = "auto", prefer_dp: bool = False
|
||||
) -> None:
|
||||
"""resolve fold-vs-replicate once real dims are known (post update_model_arch,
|
||||
pre construction); folding shards the weights, so it rules out dp for the
|
||||
lifetime of the loaded model. `batched` is the batching ceiling being > 1."""
|
||||
lifetime of the loaded model. `prefer_dp` means the runtime can engage dp."""
|
||||
if config.parallel_folding_mode is None:
|
||||
return
|
||||
group = get_folding_tp_group(config)
|
||||
@@ -138,7 +138,7 @@ def finalize_encoder_folding(
|
||||
# a batched encode prefers dp (one all_gather) over folding (an
|
||||
# all_reduce per layer), so leave a dp-capable encoder unsharded
|
||||
keep = (
|
||||
not (batched and encoder_dp_capable(config))
|
||||
not (prefer_dp and encoder_dp_capable(config))
|
||||
and encoder_folding_worthwhile(config, group.world_size)
|
||||
and group_has_measured_topology(group)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Native, TP-foldable Qwen3-VL layer-50 encoder for MiniMax H3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.base import BaseEncoderOutput
|
||||
from sglang.multimodal_gen.configs.models.encoders.minimax_h3_qwen3vl import (
|
||||
MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER,
|
||||
MiniMaxH3Qwen3VLConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.loader.weight_utils import default_weight_loader
|
||||
from sglang.multimodal_gen.runtime.models.encoders.base import TextEncoder
|
||||
from sglang.multimodal_gen.runtime.models.encoders.qwen3vl import Qwen3VLModel
|
||||
|
||||
MINIMAX_H3_QWEN3VL_HIDDEN_DIM = 5120
|
||||
_LAYER_WEIGHT_RE = re.compile(r"^model\.language_model\.layers\.(\d+)\.")
|
||||
|
||||
|
||||
def _is_unconsumed_checkpoint_weight(name: str) -> bool:
|
||||
"""Weights intentionally absent from the layer-50 feature extractor."""
|
||||
|
||||
if name == "lm_head.weight" or name.startswith("model.language_model.norm."):
|
||||
return True
|
||||
match = _LAYER_WEIGHT_RE.match(name)
|
||||
return bool(match and int(match.group(1)) >= MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER)
|
||||
|
||||
|
||||
class MiniMaxH3Qwen3VLEncoder(TextEncoder):
|
||||
"""Qwen3-VL-32B multimodal backbone ending at hidden_states[50].
|
||||
|
||||
The component loader builds and loads this module under the encoder-folding
|
||||
TP group. A TP=1/SP=8 DiT deployment therefore shards the encoder over all
|
||||
eight otherwise-idle ranks during encoding.
|
||||
"""
|
||||
|
||||
supports_dp_encode = True
|
||||
|
||||
@staticmethod
|
||||
def should_materialize_checkpoint_weight(name: str) -> bool:
|
||||
return (
|
||||
"rotary_emb.inv_freq" not in name
|
||||
and not _is_unconsumed_checkpoint_weight(name)
|
||||
)
|
||||
|
||||
def __init__(self, config: MiniMaxH3Qwen3VLConfig) -> None:
|
||||
super().__init__(config)
|
||||
arch = config.arch_config
|
||||
selected_layer = MINIMAX_H3_QWEN3VL_SELECTED_LM_LAYER
|
||||
if int(arch.text_config.num_hidden_layers) != selected_layer:
|
||||
raise ValueError(
|
||||
"MiniMax H3 Qwen3-VL config must be trimmed to "
|
||||
f"{selected_layer} language layers before construction"
|
||||
)
|
||||
self.model = Qwen3VLModel(arch, use_tensor_parallel=True)
|
||||
# H3 consumes the unnormalized output immediately after layer 49.
|
||||
self.model.language_model.norm = nn.Identity()
|
||||
self.image_token_id = int(arch.image_token_id)
|
||||
self.video_token_id = int(arch.video_token_id)
|
||||
self.selected_lm_layer = selected_layer
|
||||
self.hidden_dim = MINIMAX_H3_QWEN3VL_HIDDEN_DIM
|
||||
|
||||
@property
|
||||
def device(self) -> torch.device:
|
||||
return next(self.parameters()).device
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(
|
||||
self,
|
||||
input_ids: torch.Tensor | None,
|
||||
position_ids: torch.Tensor | None = None,
|
||||
attention_mask: torch.Tensor | None = None,
|
||||
inputs_embeds: torch.Tensor | None = None,
|
||||
output_attentions: bool | None = None,
|
||||
output_hidden_states: bool | None = None,
|
||||
**kwargs: Any,
|
||||
) -> BaseEncoderOutput:
|
||||
outputs = self.model(
|
||||
input_ids=input_ids,
|
||||
position_ids=position_ids,
|
||||
attention_mask=attention_mask,
|
||||
inputs_embeds=inputs_embeds,
|
||||
output_attentions=False,
|
||||
output_hidden_states=False,
|
||||
return_dict=True,
|
||||
use_cache=False,
|
||||
**kwargs,
|
||||
)
|
||||
return BaseEncoderOutput(last_hidden_state=outputs.last_hidden_state)
|
||||
|
||||
@torch.no_grad()
|
||||
def encode_ids(
|
||||
self,
|
||||
input_ids: torch.Tensor,
|
||||
*,
|
||||
pixel_values: torch.Tensor | None = None,
|
||||
image_grid_thw: torch.Tensor | None = None,
|
||||
pixel_values_videos: torch.Tensor | None = None,
|
||||
video_grid_thw: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
if input_ids.dim() != 1:
|
||||
raise ValueError(f"input_ids must be 1-D, got {list(input_ids.shape)}")
|
||||
if (pixel_values is None) != (image_grid_thw is None):
|
||||
raise ValueError("pixel_values and image_grid_thw must be given together")
|
||||
if (pixel_values_videos is None) != (video_grid_thw is None):
|
||||
raise ValueError(
|
||||
"pixel_values_videos and video_grid_thw must be given together"
|
||||
)
|
||||
|
||||
host_ids = input_ids.to(device="cpu", dtype=torch.long)[None]
|
||||
host_image_grid_thw = (
|
||||
image_grid_thw.to(device="cpu", dtype=torch.long)
|
||||
if image_grid_thw is not None
|
||||
else None
|
||||
)
|
||||
host_video_grid_thw = (
|
||||
video_grid_thw.to(device="cpu", dtype=torch.long)
|
||||
if video_grid_thw is not None
|
||||
else None
|
||||
)
|
||||
position_ids = None
|
||||
if host_image_grid_thw is not None or host_video_grid_thw is not None:
|
||||
position_ids, _ = self.model.get_rope_index(
|
||||
host_ids,
|
||||
host_image_grid_thw,
|
||||
host_video_grid_thw,
|
||||
attention_mask=torch.ones_like(host_ids),
|
||||
)
|
||||
ids = host_ids.to(self.device)
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"input_ids": ids,
|
||||
"attention_mask": torch.ones_like(ids),
|
||||
"output_attentions": False,
|
||||
"output_hidden_states": False,
|
||||
"return_dict": True,
|
||||
"use_cache": False,
|
||||
}
|
||||
if position_ids is not None:
|
||||
call_kwargs["position_ids"] = position_ids.to(self.device)
|
||||
if pixel_values is not None:
|
||||
call_kwargs["pixel_values"] = pixel_values.to(self.device, torch.bfloat16)
|
||||
call_kwargs["image_grid_thw"] = host_image_grid_thw
|
||||
if pixel_values_videos is not None:
|
||||
call_kwargs["pixel_values_videos"] = pixel_values_videos.to(
|
||||
self.device, torch.bfloat16
|
||||
)
|
||||
call_kwargs["video_grid_thw"] = host_video_grid_thw
|
||||
|
||||
hidden = self.model(**call_kwargs).last_hidden_state[0].to(torch.bfloat16)
|
||||
expected_shape = [int(ids.shape[1]), self.hidden_dim]
|
||||
if list(hidden.shape) != expected_shape:
|
||||
raise ValueError(
|
||||
f"unexpected hidden shape {list(hidden.shape)}, "
|
||||
f"expected {expected_shape}"
|
||||
)
|
||||
return hidden
|
||||
|
||||
def load_weights(
|
||||
self,
|
||||
weights: Iterable[tuple[str, torch.Tensor]],
|
||||
) -> set[str]:
|
||||
params = dict(self.named_parameters(remove_duplicate=False))
|
||||
loaded: set[str] = set()
|
||||
for name, loaded_weight in weights:
|
||||
if not self.should_materialize_checkpoint_weight(name):
|
||||
continue
|
||||
param = params.get(name)
|
||||
if param is None:
|
||||
raise KeyError(
|
||||
f"Unexpected MiniMax H3 Qwen3-VL checkpoint weight: {name}"
|
||||
)
|
||||
weight_loader = getattr(param, "weight_loader", default_weight_loader)
|
||||
try:
|
||||
weight_loader(param, loaded_weight.to(param.dtype))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Failed to load MiniMax H3 Qwen3-VL weight "
|
||||
f"{name!r}: checkpoint={tuple(loaded_weight.shape)}, "
|
||||
f"parameter={tuple(param.shape)}"
|
||||
) from exc
|
||||
loaded.add(name)
|
||||
return loaded
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3Qwen3VLEncoder
|
||||
|
||||
__all__ = ["MiniMaxH3Qwen3VLEncoder"]
|
||||
@@ -4,9 +4,10 @@ from transformers import (
|
||||
Cache,
|
||||
DynamicCache,
|
||||
)
|
||||
from transformers.masking_utils import create_causal_mask
|
||||
from transformers.modeling_flash_attention_utils import FlashAttentionKwargs
|
||||
from transformers.utils import TransformersKwargs, is_torchdynamo_compiling
|
||||
from transformers.utils.generic import is_flash_attention_requested
|
||||
from transformers.vision_utils import get_vision_cu_seqlens, get_vision_position_ids
|
||||
|
||||
from sglang.multimodal_gen.configs.models.encoders.qwen3vl import Qwen3VLConfig
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
@@ -208,7 +209,11 @@ class Qwen3VLTextAttention(nn.Module):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.layer_idx = layer_idx
|
||||
self.head_dim = config.hidden_size // config.num_attention_heads
|
||||
self.head_dim = (
|
||||
int(config.head_dim)
|
||||
if getattr(config, "head_dim", None) is not None
|
||||
else config.hidden_size // config.num_attention_heads
|
||||
)
|
||||
self.total_num_heads = config.num_attention_heads
|
||||
self.total_num_key_value_heads = config.num_key_value_heads
|
||||
tp_size = _tp_world_size() if use_tensor_parallel else 1
|
||||
@@ -582,14 +587,6 @@ class Qwen3VLTextModel(nn.Module):
|
||||
else:
|
||||
text_position_ids = position_ids[0]
|
||||
|
||||
attention_mask = create_causal_mask(
|
||||
config=self.config,
|
||||
inputs_embeds=inputs_embeds,
|
||||
attention_mask=attention_mask,
|
||||
past_key_values=past_key_values,
|
||||
position_ids=text_position_ids,
|
||||
)
|
||||
|
||||
hidden_states = inputs_embeds
|
||||
|
||||
# create position embeddings to be shared across the decoder layers
|
||||
@@ -651,7 +648,8 @@ class Qwen3VLTextModel(nn.Module):
|
||||
):
|
||||
visual_pos_masks = visual_pos_masks.to(hidden_states.device)
|
||||
visual_embeds = visual_embeds.to(hidden_states.device, hidden_states.dtype)
|
||||
local_this = hidden_states[visual_pos_masks, :].clone() + visual_embeds
|
||||
local_this = hidden_states[visual_pos_masks, :]
|
||||
local_this.add_(visual_embeds)
|
||||
hidden_states[visual_pos_masks, :] = local_this
|
||||
return hidden_states
|
||||
|
||||
@@ -664,10 +662,13 @@ class Qwen3VLModel(nn.Module):
|
||||
config: Qwen3VLConfig
|
||||
_no_split_modules = ["Qwen3VLTextDecoderLayer", "Qwen3VLVisionBlock"]
|
||||
|
||||
def __init__(self, config):
|
||||
def __init__(self, config, *, use_tensor_parallel: bool = False):
|
||||
super().__init__()
|
||||
self.visual = Qwen3VLVisionModel._from_config(config.vision_config)
|
||||
self.language_model = Qwen3VLTextModel(config.text_config)
|
||||
self.language_model = Qwen3VLTextModel(
|
||||
config.text_config,
|
||||
use_tensor_parallel=use_tensor_parallel,
|
||||
)
|
||||
self.rope_deltas = None # cache rope_deltas here
|
||||
self.config = config
|
||||
|
||||
@@ -868,6 +869,25 @@ class Qwen3VLModel(nn.Module):
|
||||
# Same implementation as for images
|
||||
return self.get_image_features(pixel_values_videos, video_grid_thw)
|
||||
|
||||
def _get_flat_visual_features(
|
||||
self,
|
||||
pixel_values: torch.FloatTensor,
|
||||
grid_thw: Optional[torch.LongTensor],
|
||||
):
|
||||
pixel_values = pixel_values.type(self.visual.dtype)
|
||||
vision_kwargs = {}
|
||||
if grid_thw is not None and grid_thw.device.type == "cpu":
|
||||
if not is_flash_attention_requested(self.visual.config):
|
||||
vision_kwargs = {
|
||||
"position_ids": get_vision_position_ids(
|
||||
grid_thw, self.visual.spatial_merge_size
|
||||
).to(pixel_values.device),
|
||||
"cu_seqlens": get_vision_cu_seqlens(grid_thw),
|
||||
}
|
||||
grid_thw = grid_thw.to(pixel_values.device)
|
||||
visual_out = self.visual(pixel_values, grid_thw=grid_thw, **vision_kwargs)
|
||||
return visual_out.pooler_output, visual_out.deepstack_features
|
||||
|
||||
def get_image_features(
|
||||
self,
|
||||
pixel_values: torch.FloatTensor,
|
||||
@@ -882,10 +902,9 @@ class Qwen3VLModel(nn.Module):
|
||||
image_grid_thw (`torch.LongTensor` of shape `(num_images, 3)`, *optional*):
|
||||
The temporal, height and width of feature shape of each image in LLM.
|
||||
"""
|
||||
pixel_values = pixel_values.type(self.visual.dtype)
|
||||
visual_out = self.visual(pixel_values, grid_thw=image_grid_thw)
|
||||
image_embeds = visual_out.pooler_output
|
||||
deepstack_image_embeds = visual_out.deepstack_features
|
||||
image_embeds, deepstack_image_embeds = self._get_flat_visual_features(
|
||||
pixel_values, image_grid_thw
|
||||
)
|
||||
split_sizes = (
|
||||
image_grid_thw.prod(-1) // self.visual.spatial_merge_size**2
|
||||
).tolist()
|
||||
@@ -996,35 +1015,40 @@ class Qwen3VLModel(nn.Module):
|
||||
return_dict if return_dict is not None else self.config.use_return_dict
|
||||
)
|
||||
|
||||
if inputs_embeds is None:
|
||||
inputs_embeds_owned = inputs_embeds is None
|
||||
if inputs_embeds_owned:
|
||||
inputs_embeds = self.get_input_embeddings()(input_ids)
|
||||
|
||||
image_mask = None
|
||||
video_mask = None
|
||||
|
||||
if pixel_values is not None:
|
||||
image_embeds, deepstack_image_embeds = self.get_image_features( # long
|
||||
image_embeds, deepstack_image_embeds = self._get_flat_visual_features(
|
||||
pixel_values, image_grid_thw
|
||||
)
|
||||
image_embeds = torch.cat(image_embeds, dim=0).to(
|
||||
inputs_embeds.device, inputs_embeds.dtype
|
||||
)
|
||||
image_embeds = image_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
|
||||
image_mask, _ = self.get_placeholder_mask(
|
||||
input_ids, inputs_embeds=inputs_embeds, image_features=image_embeds
|
||||
)
|
||||
inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)
|
||||
if inputs_embeds_owned:
|
||||
inputs_embeds.masked_scatter_(image_mask, image_embeds)
|
||||
else:
|
||||
inputs_embeds = inputs_embeds.masked_scatter(image_mask, image_embeds)
|
||||
inputs_embeds_owned = True
|
||||
|
||||
if pixel_values_videos is not None:
|
||||
video_embeds, deepstack_video_embeds = self.get_video_features(
|
||||
video_embeds, deepstack_video_embeds = self._get_flat_visual_features(
|
||||
pixel_values_videos, video_grid_thw
|
||||
)
|
||||
video_embeds = torch.cat(video_embeds, dim=0).to(
|
||||
inputs_embeds.device, inputs_embeds.dtype
|
||||
)
|
||||
video_embeds = video_embeds.to(inputs_embeds.device, inputs_embeds.dtype)
|
||||
_, video_mask = self.get_placeholder_mask(
|
||||
input_ids, inputs_embeds=inputs_embeds, video_features=video_embeds
|
||||
)
|
||||
inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds)
|
||||
if inputs_embeds_owned:
|
||||
inputs_embeds.masked_scatter_(video_mask, video_embeds)
|
||||
else:
|
||||
inputs_embeds = inputs_embeds.masked_scatter(video_mask, video_embeds)
|
||||
inputs_embeds_owned = True
|
||||
|
||||
visual_pos_masks = None
|
||||
deepstack_visual_embeds = None
|
||||
@@ -1040,8 +1064,8 @@ class Qwen3VLModel(nn.Module):
|
||||
deepstack_image_embeds, deepstack_video_embeds
|
||||
):
|
||||
embed_joint = img_embed.new_zeros(
|
||||
visual_pos_masks.sum(), img_embed.shape[-1]
|
||||
).to(img_embed.device)
|
||||
img_embed.shape[0] + vid_embed.shape[0], img_embed.shape[-1]
|
||||
)
|
||||
embed_joint[image_mask_joint, :] = img_embed
|
||||
embed_joint[video_mask_joint, :] = vid_embed
|
||||
deepstack_visual_embeds.append(embed_joint)
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _require_finite_tensor(tensor: torch.Tensor, name: str) -> None:
|
||||
if not bool(torch.isfinite(tensor).all().item()):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
|
||||
|
||||
def _validate_unit_timestep(timestep: torch.Tensor, name: str) -> None:
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError(f"{name} must be a torch.Tensor")
|
||||
if not torch.is_floating_point(timestep):
|
||||
raise ValueError(f"{name} must be a floating point tensor")
|
||||
_require_finite_tensor(timestep, name)
|
||||
out_of_range = (timestep < 0) | (timestep > 1)
|
||||
if bool(out_of_range.any().item()):
|
||||
raise ValueError(f"{name} must be in [0, 1]")
|
||||
|
||||
|
||||
def _validate_sigma(value: float, name: str) -> float:
|
||||
sigma = float(value)
|
||||
if not math.isfinite(sigma):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
if sigma < 0.0:
|
||||
raise ValueError(f"{name} must be non-negative")
|
||||
return sigma
|
||||
|
||||
|
||||
def _validate_timestep_sigma_pair(
|
||||
timestep: torch.Tensor,
|
||||
sigma_curr: float,
|
||||
name: str,
|
||||
) -> float:
|
||||
_validate_unit_timestep(timestep, f"{name}_timestep")
|
||||
sigma = _validate_sigma(sigma_curr, f"{name}_sigma_curr")
|
||||
expected = 1.0 - timestep.detach().to(dtype=torch.float32)
|
||||
actual = torch.full_like(expected, sigma)
|
||||
if not torch.allclose(actual, expected, rtol=1e-5, atol=1e-5):
|
||||
raise ValueError(f"{name}_sigma_curr must equal 1 - {name}_timestep")
|
||||
return sigma
|
||||
|
||||
|
||||
def minimax_h3_rf_v_to_x0(
|
||||
xt: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
if xt.shape != v.shape:
|
||||
raise ValueError(f"xt and v shapes must match, got {xt.shape} vs {v.shape}")
|
||||
if not torch.is_floating_point(xt):
|
||||
raise ValueError("xt must be a floating point tensor")
|
||||
if not torch.is_floating_point(v):
|
||||
raise ValueError("v must be a floating point tensor")
|
||||
_require_finite_tensor(xt, "xt")
|
||||
_require_finite_tensor(v, "v")
|
||||
_validate_unit_timestep(timestep, "timestep")
|
||||
x0 = _minimax_h3_rf_v_to_x0(xt, v, timestep)
|
||||
_require_finite_tensor(x0, "x0")
|
||||
return x0
|
||||
|
||||
|
||||
def _minimax_h3_rf_v_to_x0(
|
||||
xt: torch.Tensor,
|
||||
v: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
cond_t = timestep.to(device=xt.device, dtype=xt.dtype)
|
||||
while cond_t.ndim < xt.ndim:
|
||||
cond_t = cond_t.unsqueeze(-1)
|
||||
sigma_t = 1 - cond_t
|
||||
return xt + sigma_t * v
|
||||
|
||||
|
||||
def minimax_h3_euler_eta0_step(
|
||||
state: torch.Tensor,
|
||||
denoised: torch.Tensor,
|
||||
*,
|
||||
sigma_curr: float,
|
||||
sigma_next: float,
|
||||
) -> torch.Tensor:
|
||||
if state.shape != denoised.shape:
|
||||
raise ValueError(
|
||||
f"state and denoised shapes must match, got {state.shape} vs "
|
||||
f"{denoised.shape}"
|
||||
)
|
||||
if not torch.is_floating_point(state):
|
||||
raise ValueError("state must be a floating point tensor")
|
||||
if not torch.is_floating_point(denoised):
|
||||
raise ValueError("denoised must be a floating point tensor")
|
||||
_require_finite_tensor(state, "state")
|
||||
_require_finite_tensor(denoised, "denoised")
|
||||
sigma_curr = _validate_sigma(sigma_curr, "sigma_curr")
|
||||
sigma_next = _validate_sigma(sigma_next, "sigma_next")
|
||||
if sigma_curr == 0.0 and sigma_next != 0.0:
|
||||
raise ValueError("sigma_next must be 0 when sigma_curr is 0")
|
||||
out = _minimax_h3_euler_eta0_step(
|
||||
state,
|
||||
denoised,
|
||||
sigma_curr=sigma_curr,
|
||||
sigma_next=sigma_next,
|
||||
)
|
||||
_require_finite_tensor(out, "euler_eta0_step output")
|
||||
return out
|
||||
|
||||
|
||||
def _minimax_h3_euler_eta0_step(
|
||||
state: torch.Tensor,
|
||||
denoised: torch.Tensor,
|
||||
*,
|
||||
sigma_curr: float,
|
||||
sigma_next: float,
|
||||
sigma_ratio: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
if sigma_curr == 0.0:
|
||||
return state
|
||||
compute_dtype = torch.float32
|
||||
if state.dtype not in (torch.float16, torch.bfloat16):
|
||||
compute_dtype = state.dtype
|
||||
if sigma_ratio is None:
|
||||
sigma_curr_t = state.new_tensor(sigma_curr, dtype=compute_dtype)
|
||||
sigma_next_t = state.new_tensor(sigma_next, dtype=compute_dtype)
|
||||
ratio = sigma_next_t / sigma_curr_t
|
||||
else:
|
||||
ratio = sigma_ratio.to(device=state.device, dtype=compute_dtype)
|
||||
out = ratio * state.to(dtype=compute_dtype) + (1.0 - ratio) * denoised.to(
|
||||
dtype=compute_dtype
|
||||
)
|
||||
return out.to(dtype=state.dtype)
|
||||
|
||||
|
||||
class MiniMaxH3EulerAncestralEta0SchedulerAdapter:
|
||||
def __init__(self, **config: Any) -> None:
|
||||
if config:
|
||||
raise ValueError(
|
||||
f"{type(self).__name__} does not accept config fields: "
|
||||
f"{sorted(config)}"
|
||||
)
|
||||
|
||||
def set_shift(self, _flow_shift: float) -> None:
|
||||
"""Ignore flow shift, matching the previous loader-specific path."""
|
||||
|
||||
def step_denoising(
|
||||
self,
|
||||
*,
|
||||
input_visual_latent: torch.Tensor,
|
||||
input_audio_latent: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
noise_pred_visual: torch.Tensor,
|
||||
noise_pred_audio: torch.Tensor,
|
||||
sigma_curr: float,
|
||||
sigma_next: float,
|
||||
video_timestep: torch.Tensor | None = None,
|
||||
audio_timestep: torch.Tensor | None = None,
|
||||
video_sigma_curr: float | None = None,
|
||||
video_sigma_next: float | None = None,
|
||||
audio_sigma_curr: float | None = None,
|
||||
audio_sigma_next: float | None = None,
|
||||
) -> dict[str, torch.Tensor]:
|
||||
visual_timestep = timestep if video_timestep is None else video_timestep
|
||||
audio_timestep = timestep if audio_timestep is None else audio_timestep
|
||||
visual_sigma_curr = sigma_curr if video_sigma_curr is None else video_sigma_curr
|
||||
visual_sigma_next = sigma_next if video_sigma_next is None else video_sigma_next
|
||||
audio_sigma_curr = sigma_curr if audio_sigma_curr is None else audio_sigma_curr
|
||||
audio_sigma_next = sigma_next if audio_sigma_next is None else audio_sigma_next
|
||||
visual_sigma_curr = _validate_timestep_sigma_pair(
|
||||
visual_timestep,
|
||||
visual_sigma_curr,
|
||||
"video",
|
||||
)
|
||||
audio_sigma_curr = _validate_timestep_sigma_pair(
|
||||
audio_timestep,
|
||||
audio_sigma_curr,
|
||||
"audio",
|
||||
)
|
||||
|
||||
denoised_visual = minimax_h3_rf_v_to_x0(
|
||||
input_visual_latent,
|
||||
noise_pred_visual,
|
||||
visual_timestep,
|
||||
)
|
||||
denoised_audio = minimax_h3_rf_v_to_x0(
|
||||
input_audio_latent,
|
||||
noise_pred_audio,
|
||||
audio_timestep,
|
||||
)
|
||||
return {
|
||||
"output_visual_latent": minimax_h3_euler_eta0_step(
|
||||
input_visual_latent,
|
||||
denoised_visual,
|
||||
sigma_curr=visual_sigma_curr,
|
||||
sigma_next=visual_sigma_next,
|
||||
),
|
||||
"output_audio_latent": minimax_h3_euler_eta0_step(
|
||||
input_audio_latent,
|
||||
denoised_audio,
|
||||
sigma_curr=audio_sigma_curr,
|
||||
sigma_next=audio_sigma_next,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3EulerAncestralEta0SchedulerAdapter
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3EulerAncestralEta0SchedulerAdapter",
|
||||
"minimax_h3_euler_eta0_step",
|
||||
"minimax_h3_rf_v_to_x0",
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_audio import (
|
||||
MiniMaxH3AudioVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
|
||||
LayerwiseOffloadableModuleMixin,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.vaes.minimax_h3_audio_vae import (
|
||||
DacAudioVAE,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.models.vaes.minimax_h3_video_vae import (
|
||||
AutoencoderKLLegacy,
|
||||
)
|
||||
|
||||
|
||||
class MiniMaxH3VideoVAE(AutoencoderKLLegacy, LayerwiseOffloadableModuleMixin):
|
||||
layerwise_offload_dit_group_enabled = False
|
||||
# EncoderFCN3D indexes its down containers instead of calling them, so they
|
||||
# cannot host layerwise hooks. Keep the small encoder resident.
|
||||
layer_names = ["decoder.transformer_blocks"]
|
||||
|
||||
def __init__(self, config: MiniMaxH3VideoVAEConfig) -> None:
|
||||
arch = config.arch_config
|
||||
parallel_decode_mode = config.resolved_parallel_decode_mode()
|
||||
use_tiled_decode = config.use_tiling and parallel_decode_mode == "tiled"
|
||||
super().__init__(
|
||||
in_channels=3,
|
||||
out_ch=3,
|
||||
ch=128,
|
||||
embed_dim=24,
|
||||
z_channels=24,
|
||||
use_3d_conv=True,
|
||||
zq_ch_encoder=None,
|
||||
zq_ch_decoder=None,
|
||||
num_res_blocks=2,
|
||||
num_res_blocks_decoder=None,
|
||||
ch_mult=[1, 2, 2, 4, 4, 8],
|
||||
space_down=[2, 2, 2, 2, 1, 1],
|
||||
space_up=[1, 2, 2, 2, 2, 1],
|
||||
time_down=[1, 2, 2, 1, 1, 1],
|
||||
time_up=None,
|
||||
padding_mode="reflect",
|
||||
padding_mode_t=None,
|
||||
use_t_isolated_gn=True,
|
||||
causal_encoder=True,
|
||||
causal_decoder=False,
|
||||
use_vit_decoder=True,
|
||||
vit_decoder_kwargs={
|
||||
"dim_head": 64,
|
||||
"ffn_activation_fn": "silu",
|
||||
"ffn_use_gated": True,
|
||||
"heads": 32,
|
||||
"norm_affine": True,
|
||||
"norm_type": "rms_norm",
|
||||
"num_layers": 36,
|
||||
"qk_norm_affine": False,
|
||||
"qk_norm_type": "rms_norm",
|
||||
"rope_dim_ratio": 0.75,
|
||||
"rope_theta": 100.0,
|
||||
},
|
||||
shift_factor=0.0,
|
||||
scaling_factor=1.0,
|
||||
pixel_norm_type="imagenet",
|
||||
clip_length=arch.vae_clip_length,
|
||||
token_drop=arch.vae_token_drop,
|
||||
encoder_tiling=bool(arch.vae_encoder_tiling),
|
||||
decoder_tiling=use_tiled_decode,
|
||||
parallel_tiling=use_tiled_decode
|
||||
and config.use_parallel_decode
|
||||
and config.use_parallel_tiling
|
||||
and bool(arch.vae_parallel_tiling),
|
||||
tile_size=int(arch.vae_tile_size),
|
||||
tile_overlap_min=int(arch.vae_tile_overlap_min),
|
||||
encoder_parallel=False,
|
||||
decoder_parallel=False,
|
||||
chunk_dim=int(arch.vae_chunk_dim),
|
||||
)
|
||||
self.sglang_config = config
|
||||
self.use_parallel_decode = config.use_parallel_decode
|
||||
self.parallel_decode_mode = parallel_decode_mode
|
||||
|
||||
def prepare_decoder_autocast_weights(self, dtype) -> int:
|
||||
return self.decoder.prepare_autocast_linear_weights(dtype)
|
||||
|
||||
|
||||
class MiniMaxH3AudioVAE(DacAudioVAE, LayerwiseOffloadableModuleMixin):
|
||||
layerwise_offload_dit_group_enabled = False
|
||||
# BigVGAN stores each executable upsampler inside a one-element ModuleList.
|
||||
# The outer ``decoder.ups`` containers are indexed but never called, so hooks
|
||||
# must target the inner lists whose ConvTranspose1d modules run forward.
|
||||
layer_names = [
|
||||
"encoder.block",
|
||||
*(f"decoder.ups.{index}" for index in range(7)),
|
||||
"decoder.resblocks",
|
||||
]
|
||||
|
||||
def __init__(self, config: MiniMaxH3AudioVAEConfig) -> None:
|
||||
super().__init__(
|
||||
encoder_dim=64,
|
||||
encoder_rates=[2, 4, 4, 5, 5],
|
||||
latent_dim=2048,
|
||||
decoder_dim=1024,
|
||||
decoder_rates=[5, 5, 2, 2, 2, 2, 2],
|
||||
sample_rate=32000,
|
||||
vae_latent_channels=32,
|
||||
attn_proj=True,
|
||||
decoder_type="bigvgan",
|
||||
)
|
||||
self.config = config
|
||||
|
||||
|
||||
EntryClass = [MiniMaxH3VideoVAE, MiniMaxH3AudioVAE]
|
||||
|
||||
__all__ = ["MiniMaxH3AudioVAE", "MiniMaxH3VideoVAE"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .audio_vae import DacAudioVAE
|
||||
|
||||
__all__ = ["DacAudioVAE"]
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Adapted from https://github.com/junjun3518/alias-free-torch under the Apache License 2.0
|
||||
|
||||
import math
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
if "sinc" in dir(torch):
|
||||
sinc = torch.sinc
|
||||
else:
|
||||
# This code is adopted from adefossez's julius.core.sinc under the MIT License
|
||||
# https://adefossez.github.io/julius/julius/core.html
|
||||
def sinc(x: torch.Tensor):
|
||||
"""
|
||||
Implementation of sinc, i.e. sin(pi * x) / (pi * x)
|
||||
__Warning__: Different to julius.sinc, the input is multiplied by `pi`!
|
||||
"""
|
||||
return torch.where(
|
||||
x == 0,
|
||||
torch.tensor(1.0, device=x.device, dtype=x.dtype),
|
||||
torch.sin(math.pi * x) / math.pi / x,
|
||||
)
|
||||
|
||||
|
||||
# This code is adopted from adefossez's julius.lowpass.LowPassFilters under the MIT License
|
||||
# https://adefossez.github.io/julius/julius/lowpass.html
|
||||
def kaiser_sinc_filter1d(
|
||||
cutoff, half_width, kernel_size
|
||||
): # return filter [1,1,kernel_size]
|
||||
even = kernel_size % 2 == 0
|
||||
half_size = kernel_size // 2
|
||||
|
||||
# For kaiser window
|
||||
delta_f = 4 * half_width
|
||||
A = 2.285 * (half_size - 1) * math.pi * delta_f + 7.95
|
||||
if A > 50.0:
|
||||
beta = 0.1102 * (A - 8.7)
|
||||
elif A >= 21.0:
|
||||
beta = 0.5842 * (A - 21) ** 0.4 + 0.07886 * (A - 21.0)
|
||||
else:
|
||||
beta = 0.0
|
||||
window = torch.kaiser_window(kernel_size, beta=beta, periodic=False)
|
||||
|
||||
# ratio = 0.5/cutoff -> 2 * cutoff = 1 / ratio
|
||||
if even:
|
||||
time = torch.arange(-half_size, half_size) + 0.5
|
||||
else:
|
||||
time = torch.arange(kernel_size) - half_size
|
||||
if cutoff == 0:
|
||||
filter_ = torch.zeros_like(time)
|
||||
else:
|
||||
filter_ = 2 * cutoff * window * sinc(2 * cutoff * time)
|
||||
"""
|
||||
Normalize filter to have sum = 1, otherwise we will have a small leakage of the constant component in the input signal.
|
||||
"""
|
||||
filter_ /= filter_.sum()
|
||||
filter = filter_.view(1, 1, kernel_size)
|
||||
|
||||
return filter
|
||||
|
||||
|
||||
class LowPassFilter1d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
cutoff=0.5,
|
||||
half_width=0.6,
|
||||
stride: int = 1,
|
||||
padding: bool = True,
|
||||
padding_mode: str = "replicate",
|
||||
kernel_size: int = 12,
|
||||
):
|
||||
"""
|
||||
kernel_size should be even number for stylegan3 setup, in this implementation, odd number is also possible.
|
||||
"""
|
||||
super().__init__()
|
||||
if cutoff < -0.0:
|
||||
raise ValueError("Minimum cutoff must be larger than zero.")
|
||||
if cutoff > 0.5:
|
||||
raise ValueError("A cutoff above 0.5 does not make sense.")
|
||||
self.kernel_size = kernel_size
|
||||
self.even = kernel_size % 2 == 0
|
||||
self.pad_left = kernel_size // 2 - int(self.even)
|
||||
self.pad_right = kernel_size // 2
|
||||
self.stride = stride
|
||||
self.padding = padding
|
||||
self.padding_mode = padding_mode
|
||||
filter = kaiser_sinc_filter1d(cutoff, half_width, kernel_size)
|
||||
self.register_buffer("filter", filter)
|
||||
|
||||
# Input [B, C, T]
|
||||
def forward(self, x):
|
||||
_, C, _ = x.shape
|
||||
|
||||
if self.padding:
|
||||
x = F.pad(x, (self.pad_left, self.pad_right), mode=self.padding_mode)
|
||||
out = F.conv1d(x, self.filter.expand(C, -1, -1), stride=self.stride, groups=C)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class UpSample1d(nn.Module):
|
||||
def __init__(self, ratio=2, kernel_size=None):
|
||||
super().__init__()
|
||||
self.ratio = ratio
|
||||
self.kernel_size = (
|
||||
int(6 * ratio // 2) * 2 if kernel_size is None else kernel_size
|
||||
)
|
||||
self.stride = ratio
|
||||
self.pad = self.kernel_size // ratio - 1
|
||||
self.pad_left = self.pad * self.stride + (self.kernel_size - self.stride) // 2
|
||||
self.pad_right = (
|
||||
self.pad * self.stride + (self.kernel_size - self.stride + 1) // 2
|
||||
)
|
||||
filter = kaiser_sinc_filter1d(
|
||||
cutoff=0.5 / ratio,
|
||||
half_width=0.6 / ratio,
|
||||
kernel_size=self.kernel_size,
|
||||
)
|
||||
self.register_buffer("filter", filter)
|
||||
|
||||
def forward(self, x):
|
||||
_, C, _ = x.shape
|
||||
|
||||
x = F.pad(x, (self.pad, self.pad), mode="replicate")
|
||||
x = F.conv_transpose1d(
|
||||
x, self.filter.expand(C, -1, -1), stride=self.stride, groups=C
|
||||
)
|
||||
x.mul_(self.ratio)
|
||||
x = x[..., self.pad_left : -self.pad_right]
|
||||
|
||||
return x
|
||||
|
||||
|
||||
class DownSample1d(nn.Module):
|
||||
def __init__(self, ratio=2, kernel_size=None):
|
||||
super().__init__()
|
||||
self.ratio = ratio
|
||||
self.kernel_size = (
|
||||
int(6 * ratio // 2) * 2 if kernel_size is None else kernel_size
|
||||
)
|
||||
self.lowpass = LowPassFilter1d(
|
||||
cutoff=0.5 / ratio,
|
||||
half_width=0.6 / ratio,
|
||||
stride=ratio,
|
||||
kernel_size=self.kernel_size,
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
xx = self.lowpass(x)
|
||||
|
||||
return xx
|
||||
|
||||
|
||||
class Activation1d(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
activation,
|
||||
up_ratio: int = 2,
|
||||
down_ratio: int = 2,
|
||||
up_kernel_size: int = 12,
|
||||
down_kernel_size: int = 12,
|
||||
):
|
||||
super().__init__()
|
||||
self.up_ratio = up_ratio
|
||||
self.down_ratio = down_ratio
|
||||
self.act = activation
|
||||
self.upsample = UpSample1d(up_ratio, up_kernel_size)
|
||||
self.downsample = DownSample1d(down_ratio, down_kernel_size)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.upsample(x)
|
||||
x = self.act(x)
|
||||
x = self.downsample(x)
|
||||
|
||||
return x
|
||||
@@ -0,0 +1,307 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# DAC-lineage audio VAE: waveform encoder + BigVGAN decoder (inference-only bundle).
|
||||
import math
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch import nn
|
||||
from torch.nn.functional import scaled_dot_product_attention
|
||||
from torch.nn.utils.parametrizations import weight_norm
|
||||
|
||||
from .bigvgan import AttrDict, BigVGAN
|
||||
|
||||
|
||||
class GeGluMlp(nn.Module):
|
||||
def __init__(self, in_features, hidden_features):
|
||||
super().__init__()
|
||||
self.norm = nn.LayerNorm(in_features)
|
||||
self.act = nn.GELU(approximate="tanh")
|
||||
self.w0 = nn.Linear(in_features, hidden_features)
|
||||
self.w1 = nn.Linear(in_features, hidden_features)
|
||||
self.w2 = nn.Linear(hidden_features, in_features)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.norm(x)
|
||||
x = self.act(self.w0(x)).mul_(self.w1(x))
|
||||
x = self.w2(x)
|
||||
return x
|
||||
|
||||
|
||||
class CausalAttention(nn.Module):
|
||||
def __init__(self, in_dim, out_dim, num_heads):
|
||||
super().__init__()
|
||||
if in_dim > out_dim:
|
||||
# assert in_dim // num_heads == out_dim
|
||||
self.head_dim = in_dim // num_heads
|
||||
self.qkv = nn.Linear(in_dim, in_dim * 3, bias=False)
|
||||
self.q_bias = nn.Parameter(torch.zeros(in_dim))
|
||||
self.v_bias = nn.Parameter(torch.zeros(in_dim))
|
||||
self.register_buffer("zero_k_bias", torch.zeros(in_dim))
|
||||
else:
|
||||
# assert out_dim // num_heads == in_dim
|
||||
self.head_dim = out_dim // num_heads
|
||||
self.qkv = nn.Linear(in_dim, out_dim * 3, bias=False)
|
||||
self.q_bias = nn.Parameter(torch.zeros(out_dim))
|
||||
self.v_bias = nn.Parameter(torch.zeros(out_dim))
|
||||
self.register_buffer("zero_k_bias", torch.zeros(out_dim))
|
||||
|
||||
self.in_dim = in_dim
|
||||
self.out_dim = out_dim
|
||||
self.num_heads = num_heads
|
||||
self.scale = self.head_dim**-0.5
|
||||
self.proj = nn.Linear(out_dim, out_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
B, N, C = x.shape
|
||||
qkv = F.linear(
|
||||
input=x,
|
||||
weight=self.qkv.weight,
|
||||
bias=torch.cat((self.q_bias, self.zero_k_bias, self.v_bias)),
|
||||
)
|
||||
q, k, v = (
|
||||
qkv.reshape(B, N, 3, self.num_heads, self.head_dim)
|
||||
.permute(2, 0, 3, 1, 4)
|
||||
.unbind(0)
|
||||
)
|
||||
|
||||
x = scaled_dot_product_attention(
|
||||
q, k, v, attn_mask=None, dropout_p=0.0, is_causal=True
|
||||
)
|
||||
|
||||
if self.in_dim > self.out_dim:
|
||||
x = torch.mean(x, dim=1)
|
||||
if self.in_dim // self.num_heads != self.out_dim:
|
||||
x = nn.functional.adaptive_avg_pool1d(x, self.out_dim)
|
||||
else:
|
||||
x = x.transpose(1, 2).reshape(B, N, -1)
|
||||
x = self.proj(x)
|
||||
return x
|
||||
|
||||
|
||||
class AttnProjection(nn.Module):
|
||||
def __init__(
|
||||
self, in_dim, out_dim, num_heads, norm_layer=nn.LayerNorm, mlp_ratio=2
|
||||
):
|
||||
super().__init__()
|
||||
assert out_dim % in_dim == 0 or in_dim % out_dim == 0
|
||||
self.in_dim = in_dim
|
||||
self.out_dim = out_dim
|
||||
self.norm1 = norm_layer(in_dim)
|
||||
self.attn = CausalAttention(in_dim, out_dim, num_heads)
|
||||
self.proj = nn.Linear(in_dim, out_dim)
|
||||
self.norm3 = norm_layer(in_dim)
|
||||
|
||||
self.norm2 = norm_layer(out_dim)
|
||||
hidden_dim = int(out_dim * mlp_ratio)
|
||||
self.mlp = GeGluMlp(in_features=out_dim, hidden_features=hidden_dim)
|
||||
# self.mlp = FeedForward(out_dim, out_dim)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.proj(self.norm3(x)).add_(self.attn(self.norm1(x)))
|
||||
return self.mlp(self.norm2(x)).add_(x)
|
||||
|
||||
|
||||
def WNConv1d(*args, **kwargs):
|
||||
return weight_norm(nn.Conv1d(*args, **kwargs))
|
||||
|
||||
|
||||
@torch.jit.script
|
||||
def snake(x, alpha):
|
||||
shape = x.shape
|
||||
x = x.reshape(shape[0], shape[1], -1)
|
||||
x = x + (alpha + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
|
||||
x = x.reshape(shape)
|
||||
return x
|
||||
|
||||
|
||||
class Snake1d(nn.Module):
|
||||
def __init__(self, channels):
|
||||
super().__init__()
|
||||
self.alpha = nn.Parameter(torch.ones(1, channels, 1))
|
||||
|
||||
def forward(self, x):
|
||||
return snake(x, self.alpha)
|
||||
|
||||
|
||||
class ResidualUnit(nn.Module):
|
||||
def __init__(self, dim: int = 16, dilation: int = 1):
|
||||
super().__init__()
|
||||
pad = ((7 - 1) * dilation) // 2
|
||||
self.block = nn.Sequential(
|
||||
Snake1d(dim),
|
||||
WNConv1d(dim, dim, kernel_size=7, dilation=dilation, padding=pad),
|
||||
Snake1d(dim),
|
||||
WNConv1d(dim, dim, kernel_size=1),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
y = self.block(x)
|
||||
pad = (x.shape[-1] - y.shape[-1]) // 2
|
||||
if pad > 0:
|
||||
x = x[..., pad:-pad]
|
||||
return x + y
|
||||
|
||||
|
||||
class EncoderBlock(nn.Module):
|
||||
def __init__(self, dim: int = 16, stride: int = 1):
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
ResidualUnit(dim // 2, dilation=1),
|
||||
ResidualUnit(dim // 2, dilation=3),
|
||||
ResidualUnit(dim // 2, dilation=9),
|
||||
Snake1d(dim // 2),
|
||||
WNConv1d(
|
||||
dim // 2,
|
||||
dim,
|
||||
kernel_size=2 * stride,
|
||||
stride=stride,
|
||||
padding=math.ceil(stride / 2),
|
||||
),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.block(x)
|
||||
|
||||
|
||||
class Encoder(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int = 64,
|
||||
strides: list = [2, 4, 8, 8],
|
||||
d_latent: int = 64,
|
||||
):
|
||||
super().__init__()
|
||||
# Create first convolution
|
||||
self.block = [WNConv1d(1, d_model, kernel_size=7, padding=3)]
|
||||
|
||||
# Create EncoderBlocks that double channels as they downsample by `stride`
|
||||
for stride in strides:
|
||||
d_model *= 2
|
||||
self.block += [EncoderBlock(d_model, stride=stride)]
|
||||
|
||||
# Create last convolution
|
||||
self.block += [
|
||||
Snake1d(d_model),
|
||||
WNConv1d(d_model, d_latent, kernel_size=3, padding=1),
|
||||
]
|
||||
|
||||
# Wrap black into nn.Sequential
|
||||
self.block = nn.Sequential(*self.block)
|
||||
self.enc_dim = d_model
|
||||
|
||||
def forward(self, x):
|
||||
return self.block(x)
|
||||
|
||||
|
||||
class DacAudioVAE(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
encoder_dim: int = 64,
|
||||
encoder_rates: List[int] = [2, 4, 8, 8],
|
||||
latent_dim: int = None,
|
||||
decoder_dim: int = 1536,
|
||||
decoder_rates: List[int] = [8, 8, 4, 2],
|
||||
sample_rate: int = 44100,
|
||||
vae_latent_channels: int = 64,
|
||||
attn_proj: bool = False,
|
||||
decoder_type: str = "bigvgan",
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.encoder_dim = encoder_dim
|
||||
self.encoder_rates = encoder_rates
|
||||
self.decoder_dim = decoder_dim
|
||||
self.decoder_rates = decoder_rates
|
||||
self.sample_rate = sample_rate
|
||||
self.attn_proj = attn_proj
|
||||
self.decoder_type = decoder_type
|
||||
|
||||
if latent_dim is None:
|
||||
latent_dim = encoder_dim * (2 ** len(encoder_rates))
|
||||
|
||||
self.latent_dim = latent_dim
|
||||
|
||||
self.hop_length = np.prod(encoder_rates)
|
||||
self.encoder = Encoder(encoder_dim, encoder_rates, latent_dim)
|
||||
|
||||
if latent_dim % vae_latent_channels == 0:
|
||||
self.attn_proj_dim = vae_latent_channels
|
||||
else:
|
||||
# smallest power of two >= vae_latent_channels
|
||||
self.attn_proj_dim = 2 ** int(np.ceil(np.log2(vae_latent_channels)))
|
||||
|
||||
self.mean_proj = nn.Conv1d(self.attn_proj_dim, vae_latent_channels, 1)
|
||||
self.logs_proj = nn.Conv1d(self.attn_proj_dim, vae_latent_channels, 1)
|
||||
|
||||
self.dec_in_proj = nn.Conv1d(vae_latent_channels, latent_dim, 1)
|
||||
|
||||
if self.decoder_type == "bigvgan":
|
||||
if sample_rate == 16000:
|
||||
bigvgan_conf = {
|
||||
"resblock": "1",
|
||||
"num_mels": latent_dim,
|
||||
"upsample_rates": [5, 5, 2, 2, 2, 2],
|
||||
"upsample_kernel_sizes": [9, 9, 4, 4, 4, 4],
|
||||
"upsample_initial_channel": decoder_dim,
|
||||
"resblock_kernel_sizes": [3, 7, 11],
|
||||
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
|
||||
"use_tanh_at_final": False,
|
||||
"use_bias_at_final": False,
|
||||
"activation": "snakebeta",
|
||||
"snake_logscale": True,
|
||||
}
|
||||
elif sample_rate == 32000:
|
||||
bigvgan_conf = {
|
||||
"resblock": "1",
|
||||
"num_mels": latent_dim,
|
||||
"upsample_rates": [5, 5, 2, 2, 2, 2, 2],
|
||||
"upsample_kernel_sizes": [9, 9, 4, 4, 4, 4, 4],
|
||||
"upsample_initial_channel": decoder_dim,
|
||||
"resblock_kernel_sizes": [3, 7, 11],
|
||||
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
|
||||
"use_tanh_at_final": False,
|
||||
"use_bias_at_final": False,
|
||||
"activation": "snakebeta",
|
||||
"snake_logscale": True,
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Invalid sample_rate: {sample_rate}")
|
||||
|
||||
h = AttrDict(**bigvgan_conf)
|
||||
self.decoder = BigVGAN(h)
|
||||
else:
|
||||
raise ValueError(f"Invalid decoder type: {self.decoder_type}")
|
||||
|
||||
if self.attn_proj:
|
||||
self.pre_block = AttnProjection(latent_dim, self.attn_proj_dim, num_heads=8)
|
||||
|
||||
self.sample_rate = sample_rate
|
||||
|
||||
def preprocess(self, audio_data, sample_rate):
|
||||
if sample_rate is None:
|
||||
sample_rate = self.sample_rate
|
||||
|
||||
length = audio_data.shape[-1]
|
||||
right_pad = math.ceil(length / self.hop_length) * self.hop_length - length
|
||||
if right_pad:
|
||||
audio_data = nn.functional.pad(audio_data, (0, right_pad))
|
||||
|
||||
return audio_data
|
||||
|
||||
def decode(self, z: torch.Tensor):
|
||||
"""Decode given latent codes and return audio data
|
||||
|
||||
Parameters
|
||||
----------
|
||||
z : Tensor[B x D x T]
|
||||
Continuous latent representation
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tensor[B x 1 x length]
|
||||
Decoded audio data.
|
||||
"""
|
||||
z = self.dec_in_proj(z)
|
||||
return self.decoder(z)
|
||||
@@ -0,0 +1,255 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2024 NVIDIA CORPORATION.
|
||||
# Licensed under the MIT license.
|
||||
|
||||
# Adapted from https://github.com/jik876/hifi-gan under the MIT license.
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.nn import Conv1d, ConvTranspose1d, Parameter
|
||||
from torch.nn.utils.parametrizations import weight_norm
|
||||
|
||||
from .alias_free import Activation1d
|
||||
|
||||
|
||||
def get_padding(kernel_size, dilation=1):
|
||||
return int((kernel_size * dilation - dilation) / 2)
|
||||
|
||||
|
||||
# Adapted from https://github.com/EdwardDixon/snake under the MIT license.
|
||||
@torch.jit.script
|
||||
def snakebeta(x, alpha, beta):
|
||||
shape = x.shape
|
||||
x = x.reshape(shape[0], shape[1], -1)
|
||||
x = x + (beta + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2)
|
||||
x = x.reshape(shape)
|
||||
return x
|
||||
|
||||
|
||||
class SnakeBeta(nn.Module):
|
||||
def __init__(
|
||||
self, in_features, alpha=1.0, alpha_trainable=True, alpha_logscale=False
|
||||
):
|
||||
super(SnakeBeta, self).__init__()
|
||||
self.in_features = in_features
|
||||
|
||||
self.alpha_logscale = alpha_logscale
|
||||
if self.alpha_logscale:
|
||||
self.alpha = Parameter(torch.zeros(in_features) * alpha)
|
||||
self.beta = Parameter(torch.zeros(in_features) * alpha)
|
||||
else:
|
||||
self.alpha = Parameter(torch.ones(in_features) * alpha)
|
||||
self.beta = Parameter(torch.ones(in_features) * alpha)
|
||||
|
||||
self.alpha.requires_grad = alpha_trainable
|
||||
self.beta.requires_grad = alpha_trainable
|
||||
self.no_div_by_zero = 0.000000001
|
||||
|
||||
def forward(self, x):
|
||||
alpha = self.alpha.unsqueeze(0).unsqueeze(-1)
|
||||
beta = self.beta.unsqueeze(0).unsqueeze(-1)
|
||||
if self.alpha_logscale:
|
||||
alpha = torch.exp(alpha)
|
||||
beta = torch.exp(beta)
|
||||
x = snakebeta(x, alpha, beta)
|
||||
return x
|
||||
|
||||
|
||||
class AttrDict(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AttrDict, self).__init__(*args, **kwargs)
|
||||
self.__dict__ = self
|
||||
|
||||
|
||||
class AMPBlock1(torch.nn.Module):
|
||||
"""
|
||||
AMPBlock applies SnakeBeta activation functions with trainable parameters that control periodicity, defined for each layer.
|
||||
AMPBlock1 has additional self.convs2 that contains additional Conv1d layers with a fixed dilation=1 followed by each layer in self.convs1
|
||||
|
||||
Args:
|
||||
h (AttrDict): Hyperparameters.
|
||||
channels (int): Number of convolution channels.
|
||||
kernel_size (int): Size of the convolution kernel. Default is 3.
|
||||
dilation (tuple): Dilation rates for the convolutions. Each dilation layer has two convolutions. Default is (1, 3, 5).
|
||||
activation (str): Activation function type. Must be 'snakebeta'.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
h: AttrDict,
|
||||
channels: int,
|
||||
kernel_size: int = 3,
|
||||
dilation: tuple = (1, 3, 5),
|
||||
activation: str = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.h = h
|
||||
|
||||
self.convs1 = nn.ModuleList(
|
||||
[
|
||||
weight_norm(
|
||||
Conv1d(
|
||||
channels,
|
||||
channels,
|
||||
kernel_size,
|
||||
stride=1,
|
||||
dilation=d,
|
||||
padding=get_padding(kernel_size, d),
|
||||
)
|
||||
)
|
||||
for d in dilation
|
||||
]
|
||||
)
|
||||
|
||||
self.convs2 = nn.ModuleList(
|
||||
[
|
||||
weight_norm(
|
||||
Conv1d(
|
||||
channels,
|
||||
channels,
|
||||
kernel_size,
|
||||
stride=1,
|
||||
dilation=1,
|
||||
padding=get_padding(kernel_size, 1),
|
||||
)
|
||||
)
|
||||
for _ in range(len(dilation))
|
||||
]
|
||||
)
|
||||
|
||||
self.num_layers = len(self.convs1) + len(
|
||||
self.convs2
|
||||
) # Total number of conv layers
|
||||
|
||||
if activation == "snakebeta":
|
||||
self.activations = nn.ModuleList(
|
||||
[
|
||||
Activation1d(
|
||||
activation=SnakeBeta(channels, alpha_logscale=h.snake_logscale)
|
||||
)
|
||||
for _ in range(self.num_layers)
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"activation incorrectly specified. check the config file and look for 'activation'."
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
activation_iter = iter(self.activations)
|
||||
for c1, c2 in zip(self.convs1, self.convs2):
|
||||
a1 = next(activation_iter)
|
||||
a2 = next(activation_iter)
|
||||
xt = a1(x)
|
||||
xt = c1(xt)
|
||||
xt = a2(xt)
|
||||
xt = c2(xt)
|
||||
x = xt.add_(x)
|
||||
|
||||
return x
|
||||
|
||||
|
||||
class BigVGAN(torch.nn.Module):
|
||||
"""
|
||||
BigVGAN is a neural vocoder model that applies anti-aliased periodic activation for residual blocks (resblocks).
|
||||
|
||||
Args:
|
||||
h (AttrDict): Hyperparameters.
|
||||
"""
|
||||
|
||||
def __init__(self, h: AttrDict):
|
||||
super().__init__()
|
||||
self.h = h
|
||||
|
||||
self.num_kernels = len(h.resblock_kernel_sizes)
|
||||
self.num_upsamples = len(h.upsample_rates)
|
||||
|
||||
# Pre-conv
|
||||
self.conv_pre = weight_norm(
|
||||
Conv1d(h.num_mels, h.upsample_initial_channel, 7, 1, padding=3)
|
||||
)
|
||||
|
||||
# Define which AMPBlock to use. BigVGAN uses AMPBlock1 as default
|
||||
if h.resblock == "1":
|
||||
resblock_class = AMPBlock1
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Incorrect resblock class specified in hyperparameters. Got {h.resblock}"
|
||||
)
|
||||
|
||||
# Transposed conv-based upsamplers. does not apply anti-aliasing
|
||||
self.ups = nn.ModuleList()
|
||||
for i, (u, k) in enumerate(zip(h.upsample_rates, h.upsample_kernel_sizes)):
|
||||
self.ups.append(
|
||||
nn.ModuleList(
|
||||
[
|
||||
weight_norm(
|
||||
ConvTranspose1d(
|
||||
h.upsample_initial_channel // (2**i),
|
||||
h.upsample_initial_channel // (2 ** (i + 1)),
|
||||
k,
|
||||
u,
|
||||
padding=(k - u) // 2,
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Residual blocks using anti-aliased multi-periodicity composition modules (AMP)
|
||||
self.resblocks = nn.ModuleList()
|
||||
for i in range(len(self.ups)):
|
||||
ch = h.upsample_initial_channel // (2 ** (i + 1))
|
||||
for j, (k, d) in enumerate(
|
||||
zip(h.resblock_kernel_sizes, h.resblock_dilation_sizes)
|
||||
):
|
||||
self.resblocks.append(
|
||||
resblock_class(h, ch, k, d, activation=h.activation)
|
||||
)
|
||||
|
||||
# Post-conv
|
||||
if h.activation != "snakebeta":
|
||||
raise NotImplementedError(
|
||||
"activation incorrectly specified. check the config file and look for 'activation'."
|
||||
)
|
||||
activation_post = SnakeBeta(ch, alpha_logscale=h.snake_logscale)
|
||||
|
||||
self.activation_post = Activation1d(activation=activation_post)
|
||||
|
||||
# Whether to use bias for the final conv_post. Default to True for backward compatibility
|
||||
self.use_bias_at_final = h.get("use_bias_at_final", True)
|
||||
self.conv_post = weight_norm(
|
||||
Conv1d(ch, 1, 7, 1, padding=3, bias=self.use_bias_at_final)
|
||||
)
|
||||
|
||||
# Final tanh activation. Defaults to True for backward compatibility
|
||||
self.use_tanh_at_final = h.get("use_tanh_at_final", True)
|
||||
|
||||
def forward(self, x):
|
||||
# Pre-conv
|
||||
x = self.conv_pre(x)
|
||||
|
||||
for i in range(self.num_upsamples):
|
||||
# Upsampling
|
||||
for i_up in range(len(self.ups[i])):
|
||||
x = self.ups[i][i_up](x)
|
||||
# AMP blocks
|
||||
xs = None
|
||||
for j in range(self.num_kernels):
|
||||
if xs is None:
|
||||
xs = self.resblocks[i * self.num_kernels + j](x)
|
||||
else:
|
||||
xs += self.resblocks[i * self.num_kernels + j](x)
|
||||
x = xs.div_(self.num_kernels)
|
||||
|
||||
# Post-conv
|
||||
x = self.activation_post(x)
|
||||
x = self.conv_post(x)
|
||||
# Final tanh activation
|
||||
if self.use_tanh_at_final:
|
||||
x.tanh_()
|
||||
else:
|
||||
x.clamp_(min=-1.0, max=1.0) # Bound the output to [-1, 1]
|
||||
|
||||
return x
|
||||
@@ -0,0 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .klvae import AutoencoderKLLegacy
|
||||
|
||||
__all__ = ["AutoencoderKLLegacy"]
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Attention module for the MiniMax H3 visual VAE (inference-only bundle).
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
from diffusers.utils import logging
|
||||
|
||||
from .flash import flash_attn
|
||||
from .vit_utils import _env_flag, apply_rotary_pos_emb_qk
|
||||
|
||||
logger = logging.get_logger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def _vit_norm_input(module, hidden_states):
|
||||
if _env_flag("MINIMAX_H3_VAE_DECODER_VIT_FP32_NORM", "1"):
|
||||
return hidden_states.float()
|
||||
weight = getattr(module, "weight", None)
|
||||
return hidden_states.to(getattr(weight, "dtype", hidden_states.dtype))
|
||||
|
||||
|
||||
def _apply_qk_norm(module, hidden_states):
|
||||
if (
|
||||
_env_flag("MINIMAX_H3_VAE_DECODER_VIT_FP32_NORM", "1")
|
||||
and isinstance(module, (nn.LayerNorm, nn.RMSNorm))
|
||||
and getattr(module, "weight", None) is None
|
||||
and getattr(module, "bias", None) is None
|
||||
and hidden_states.is_cuda
|
||||
and hidden_states.dtype in (torch.float16, torch.bfloat16)
|
||||
and not torch.is_grad_enabled()
|
||||
and not torch.compiler.is_compiling()
|
||||
):
|
||||
# CUDA LayerNorm/RMSNorm accumulates half/bfloat16 inputs in FP32.
|
||||
# With no affine parameters its half output is bit-identical to the
|
||||
# released FP32-norm-then-cast recipe, without two full-tensor casts.
|
||||
with torch.autocast("cuda", enabled=False):
|
||||
return module(hidden_states)
|
||||
return module(_vit_norm_input(module, hidden_states)).to(hidden_states.dtype)
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
heads,
|
||||
dim_head,
|
||||
embed_dim: Optional[int] = None,
|
||||
qk_norm_type: Optional[str] = None,
|
||||
qk_norm_affine: bool = False,
|
||||
bias: bool = True,
|
||||
out_bias: Optional[bool] = None,
|
||||
eps: float = 1e-5,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
self.dim_head = dim_head
|
||||
self.heads = heads
|
||||
self.attn_inner_dim = dim_head * heads
|
||||
self.embed_dim = embed_dim if embed_dim is not None else self.attn_inner_dim
|
||||
|
||||
out_bias = out_bias if out_bias is not None else bias
|
||||
|
||||
if qk_norm_type is None:
|
||||
self.norm_q = None
|
||||
self.norm_k = None
|
||||
elif qk_norm_type == "layer_norm":
|
||||
self.norm_q = nn.LayerNorm(
|
||||
dim_head, eps=eps, elementwise_affine=qk_norm_affine
|
||||
)
|
||||
self.norm_k = nn.LayerNorm(
|
||||
dim_head, eps=eps, elementwise_affine=qk_norm_affine
|
||||
)
|
||||
elif qk_norm_type == "rms_norm":
|
||||
self.norm_q = nn.RMSNorm(
|
||||
dim_head, eps=eps, elementwise_affine=qk_norm_affine
|
||||
)
|
||||
self.norm_k = nn.RMSNorm(
|
||||
dim_head, eps=eps, elementwise_affine=qk_norm_affine
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"unknown qk_norm_type: {qk_norm_type}. Should be None,'layer_norm','rms_norm'"
|
||||
)
|
||||
|
||||
self.to_qkv = nn.Linear(self.embed_dim, self.attn_inner_dim * 3, bias=bias)
|
||||
|
||||
self.to_out = nn.Linear(self.attn_inner_dim, self.embed_dim, bias=out_bias)
|
||||
|
||||
if len(kwargs) > 0 and (not dist.is_initialized() or dist.get_rank() == 0):
|
||||
logger.warning(f"Unused kwargs: {kwargs}")
|
||||
|
||||
def _perform_attention(self, query, key, value, pack_info):
|
||||
cu_seqlens = pack_info.get("cu_seqlens", None)
|
||||
mask_mod = pack_info.get("mask_mod", None)
|
||||
block_sparse = pack_info.get("block_sparse", None)
|
||||
valid_seq_len = pack_info.get("valid_seq_len", None)
|
||||
|
||||
if cu_seqlens is not None:
|
||||
raise NotImplementedError(
|
||||
"varlen attention is not supported in this inference-only bundle"
|
||||
)
|
||||
|
||||
padded_seq_len = query.shape[1]
|
||||
if valid_seq_len is not None:
|
||||
valid_seq_len = int(valid_seq_len)
|
||||
if not 0 < valid_seq_len <= padded_seq_len:
|
||||
raise ValueError(
|
||||
"valid_seq_len must be in (0, padded_seq_len], got "
|
||||
f"{valid_seq_len} for padded_seq_len={padded_seq_len}"
|
||||
)
|
||||
query = query[:, :valid_seq_len]
|
||||
key = key[:, :valid_seq_len]
|
||||
value = value[:, :valid_seq_len]
|
||||
|
||||
if mask_mod is not None:
|
||||
hidden_states = flash_attn(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
mask_mod=mask_mod,
|
||||
block_sparse=block_sparse,
|
||||
)
|
||||
else:
|
||||
hidden_states = flash_attn(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
)
|
||||
|
||||
if valid_seq_len is not None and valid_seq_len < padded_seq_len:
|
||||
hidden_states = torch.cat(
|
||||
[
|
||||
hidden_states,
|
||||
hidden_states.new_zeros(
|
||||
hidden_states.shape[0],
|
||||
padded_seq_len - valid_seq_len,
|
||||
hidden_states.shape[2],
|
||||
hidden_states.shape[3],
|
||||
),
|
||||
],
|
||||
dim=1,
|
||||
)
|
||||
|
||||
return hidden_states
|
||||
|
||||
def perform_attention(self, query, key, value, pack_info={}):
|
||||
return self._perform_attention(query, key, value, pack_info)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
rotary_pos_emb: Optional[torch.Tensor] = None,
|
||||
pack_info: dict = {},
|
||||
) -> torch.Tensor:
|
||||
batch_size, seq_len, _ = hidden_states.shape
|
||||
|
||||
qkv = self.to_qkv(hidden_states)
|
||||
qkv = qkv.view(batch_size, seq_len, -1, 3 * self.dim_head)
|
||||
query, key, value = torch.chunk(qkv, 3, dim=-1)
|
||||
|
||||
if self.norm_q is not None:
|
||||
query = _apply_qk_norm(self.norm_q, query)
|
||||
if self.norm_k is not None:
|
||||
key = _apply_qk_norm(self.norm_k, key)
|
||||
|
||||
if rotary_pos_emb is not None:
|
||||
query, key = apply_rotary_pos_emb_qk(query, key, rotary_pos_emb)
|
||||
|
||||
hidden_states = self.perform_attention(query, key, value, pack_info)
|
||||
|
||||
hidden_states = hidden_states.reshape(batch_size, seq_len, -1)
|
||||
hidden_states = self.to_out(hidden_states)
|
||||
|
||||
return hidden_states
|
||||
@@ -0,0 +1,281 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Transformer building blocks for the MiniMax H3 visual VAE ViT decoder.
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from diffusers.utils import logging
|
||||
from diffusers.utils.torch_utils import maybe_allow_in_graph
|
||||
|
||||
from sglang.kernels.ops.activation.activation import (
|
||||
silu_and_mul_with_activation_rounding,
|
||||
)
|
||||
from sglang.kernels.ops.diffusion.triton.scale_shift import (
|
||||
try_fused_scaled_residual_add_exact,
|
||||
)
|
||||
|
||||
from .attention import Attention
|
||||
from .vit_utils import _env_flag, _vit_torch_compile_kwargs
|
||||
|
||||
logger = logging.get_logger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def _vit_norm_input(module, hidden_states):
|
||||
if _env_flag("MINIMAX_H3_VAE_DECODER_VIT_FP32_NORM", "1"):
|
||||
return hidden_states.float()
|
||||
return hidden_states.to(getattr(module.weight, "dtype", hidden_states.dtype))
|
||||
|
||||
|
||||
def _scaled_residual_add(residual, x, scale):
|
||||
fused = try_fused_scaled_residual_add_exact(residual, x, scale)
|
||||
return residual + x * scale if fused is None else fused
|
||||
|
||||
|
||||
class FeedForward(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dim: int,
|
||||
dim_out: Optional[int] = None,
|
||||
mult: int = 4,
|
||||
activation_fn: str = "silu",
|
||||
bias: bool = True,
|
||||
use_gated: bool = True,
|
||||
glu_balanced: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
ratio = 2 / 3 if (use_gated and glu_balanced) else 1
|
||||
inner_dim = round(dim * mult * ratio)
|
||||
dim_out = dim_out if dim_out is not None else dim
|
||||
self.use_gated = use_gated
|
||||
|
||||
if use_gated:
|
||||
self.w1 = nn.Linear(dim, inner_dim * 2, bias=bias)
|
||||
else:
|
||||
self.w1 = nn.Linear(dim, inner_dim, bias=bias)
|
||||
|
||||
if activation_fn == "silu":
|
||||
self.act_fn = nn.SiLU()
|
||||
elif activation_fn == "gelu":
|
||||
self.act_fn = nn.GELU()
|
||||
elif activation_fn == "gelu-approximate":
|
||||
self.act_fn = nn.GELU(approximate="tanh")
|
||||
else:
|
||||
raise ValueError(f"Unsupported activation function: {activation_fn}")
|
||||
|
||||
self.w2 = nn.Linear(inner_dim, dim_out, bias=bias)
|
||||
self._compile_forward_enabled = _env_flag(
|
||||
"MINIMAX_H3_VAE_DECODER_VIT_FF_TORCH_COMPILE", "0"
|
||||
)
|
||||
self._compile_forward_fatal = _env_flag(
|
||||
"MINIMAX_H3_VAE_DECODER_VIT_FF_TORCH_COMPILE_FATAL", "0"
|
||||
)
|
||||
self._compiled_forward = None
|
||||
|
||||
def _forward_impl(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
hidden_states = self.w1(hidden_states)
|
||||
|
||||
if self.use_gated:
|
||||
if (
|
||||
isinstance(self.act_fn, nn.SiLU)
|
||||
and hidden_states.is_cuda
|
||||
and hidden_states.dtype in (torch.float16, torch.bfloat16)
|
||||
and hidden_states.is_contiguous()
|
||||
and hidden_states.shape[-1] % 32 == 0
|
||||
):
|
||||
hidden_states = silu_and_mul_with_activation_rounding(hidden_states)
|
||||
else:
|
||||
gate, hidden_states = hidden_states.chunk(2, dim=-1)
|
||||
hidden_states = self.act_fn(gate).mul_(hidden_states)
|
||||
else:
|
||||
hidden_states = self.act_fn(hidden_states)
|
||||
|
||||
hidden_states = self.w2(hidden_states)
|
||||
return hidden_states
|
||||
|
||||
def _get_forward_impl(self):
|
||||
if not self._compile_forward_enabled:
|
||||
return self._forward_impl
|
||||
if self._compiled_forward is not None:
|
||||
return self._compiled_forward
|
||||
if not hasattr(torch, "compile"):
|
||||
message = (
|
||||
"torch.compile is unavailable; falling back to eager ViT FeedForward"
|
||||
)
|
||||
if self._compile_forward_fatal:
|
||||
raise RuntimeError(message)
|
||||
logger.warning(f"[ViTFeedForward] {message}")
|
||||
self._compile_forward_enabled = False
|
||||
return self._forward_impl
|
||||
|
||||
kwargs = _vit_torch_compile_kwargs(
|
||||
"MINIMAX_H3_VAE_DECODER_VIT_FF_TORCH_COMPILE"
|
||||
)
|
||||
try:
|
||||
self._compiled_forward = torch.compile(self._forward_impl, **kwargs)
|
||||
logger.info(f"[ViTFeedForward] torch.compile enabled kwargs={kwargs}")
|
||||
except Exception as exc:
|
||||
if self._compile_forward_fatal:
|
||||
raise
|
||||
logger.warning(
|
||||
f"[ViTFeedForward] torch.compile setup failed: {type(exc).__name__}: {exc}; "
|
||||
"falling back to eager"
|
||||
)
|
||||
self._compile_forward_enabled = False
|
||||
self._compiled_forward = None
|
||||
return self._forward_impl
|
||||
return self._compiled_forward
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
forward_impl = self._get_forward_impl()
|
||||
try:
|
||||
return forward_impl(hidden_states)
|
||||
except Exception as exc:
|
||||
if (
|
||||
self._compile_forward_enabled
|
||||
and self._compiled_forward is not None
|
||||
and forward_impl is self._compiled_forward
|
||||
and not self._compile_forward_fatal
|
||||
):
|
||||
logger.warning(
|
||||
f"[ViTFeedForward] compiled forward failed: {type(exc).__name__}: {exc}; "
|
||||
"disabling compile and retrying eager"
|
||||
)
|
||||
self._compile_forward_enabled = False
|
||||
self._compiled_forward = None
|
||||
return self._forward_impl(hidden_states)
|
||||
raise
|
||||
|
||||
|
||||
class RotaryEmbeddingND(nn.Module):
|
||||
def __init__(self, dim, rotary_base=10000, n_dim=3, use_angle=False):
|
||||
super().__init__()
|
||||
self.dim = dim
|
||||
self.n_dim = n_dim
|
||||
|
||||
if dim % (2 * n_dim) != 0:
|
||||
raise ValueError(
|
||||
f"head_dim {dim} must be divisible by 2 * n_dim {2 * n_dim}"
|
||||
)
|
||||
|
||||
if use_angle:
|
||||
self.angle_scale = 2.0 * math.pi
|
||||
else:
|
||||
self.angle_scale = 1.0
|
||||
|
||||
inv_freq = 1 / rotary_base ** torch.arange(
|
||||
0, 1, 2 * n_dim / dim, dtype=torch.float32
|
||||
)
|
||||
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
||||
|
||||
def forward(self, img_ids):
|
||||
B, N, D = img_ids.shape
|
||||
if D != self.n_dim:
|
||||
raise ValueError(f"Expected {self.n_dim} dimensions, got {D}")
|
||||
|
||||
with torch.autocast("cuda", enabled=False):
|
||||
angles = (
|
||||
self.angle_scale
|
||||
* img_ids[:, :, :, None]
|
||||
* self.inv_freq.to(img_ids.device)[None, None, None, :]
|
||||
)
|
||||
angles = angles.flatten(2, 3)
|
||||
angles = angles.tile(2)
|
||||
angles = angles.unsqueeze(2)
|
||||
|
||||
cos = torch.cos(angles)
|
||||
sin = torch.sin(angles)
|
||||
|
||||
return cos.to(dtype=img_ids.dtype), sin.to(dtype=img_ids.dtype)
|
||||
|
||||
|
||||
@maybe_allow_in_graph
|
||||
class TransformerBlock(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
heads: int,
|
||||
dim_head: int,
|
||||
embed_dim: Optional[int] = None,
|
||||
ffn_glu_balanced: bool = False,
|
||||
norm_type: str = "layer_norm",
|
||||
norm_affine: bool = True,
|
||||
qk_norm_type: str = "rms_norm",
|
||||
qk_norm_affine: bool = False,
|
||||
ffn_activation_fn: str = "silu",
|
||||
ffn_use_gated: bool = True,
|
||||
use_scale: bool = True,
|
||||
bias: bool = True,
|
||||
eps: float = 1e-5,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
dim = embed_dim if embed_dim is not None else dim_head * heads
|
||||
self.use_scale = use_scale
|
||||
|
||||
if norm_type == "layer_norm":
|
||||
norm_class = nn.LayerNorm
|
||||
elif norm_type == "rms_norm":
|
||||
norm_class = nn.RMSNorm
|
||||
else:
|
||||
raise ValueError(f"unknown norm_type {norm_type}")
|
||||
|
||||
self.norm1 = norm_class(
|
||||
dim,
|
||||
elementwise_affine=norm_affine,
|
||||
eps=eps,
|
||||
)
|
||||
self.attn = Attention(
|
||||
heads=heads,
|
||||
dim_head=dim_head,
|
||||
embed_dim=dim,
|
||||
qk_norm_type=qk_norm_type,
|
||||
qk_norm_affine=qk_norm_affine,
|
||||
bias=bias,
|
||||
eps=eps,
|
||||
**kwargs,
|
||||
)
|
||||
if use_scale:
|
||||
self.scale1 = nn.Parameter(torch.zeros(dim))
|
||||
|
||||
self.norm2 = norm_class(
|
||||
dim,
|
||||
elementwise_affine=norm_affine,
|
||||
eps=eps,
|
||||
)
|
||||
self.ff = FeedForward(
|
||||
dim=dim,
|
||||
activation_fn=ffn_activation_fn,
|
||||
bias=bias,
|
||||
use_gated=ffn_use_gated,
|
||||
glu_balanced=ffn_glu_balanced,
|
||||
)
|
||||
if use_scale:
|
||||
self.scale2 = nn.Parameter(torch.zeros(dim))
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.FloatTensor,
|
||||
rotary_pos_emb: Optional[torch.FloatTensor] = None,
|
||||
pack_info: dict = {},
|
||||
):
|
||||
norm_hidden_states = self.norm1(_vit_norm_input(self.norm1, hidden_states)).to(
|
||||
hidden_states.dtype
|
||||
)
|
||||
attn_output = self.attn(norm_hidden_states, rotary_pos_emb, pack_info)
|
||||
if self.use_scale:
|
||||
hidden_states = _scaled_residual_add(
|
||||
hidden_states, attn_output, self.scale1
|
||||
)
|
||||
else:
|
||||
hidden_states = hidden_states + attn_output
|
||||
|
||||
norm_hidden_states = self.norm2(_vit_norm_input(self.norm2, hidden_states)).to(
|
||||
hidden_states.dtype
|
||||
)
|
||||
ff_output = self.ff(norm_hidden_states)
|
||||
if self.use_scale:
|
||||
hidden_states = _scaled_residual_add(hidden_states, ff_output, self.scale2)
|
||||
else:
|
||||
hidden_states = hidden_states + ff_output
|
||||
|
||||
return hidden_states
|
||||
@@ -0,0 +1,83 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# 3D convolution for the MiniMax H3 visual VAE.
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
class BaseConv3d(nn.Conv3d):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size,
|
||||
stride=1,
|
||||
padding=0,
|
||||
bias=True,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
):
|
||||
super().__init__(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=kernel_size,
|
||||
stride=stride,
|
||||
padding=padding,
|
||||
bias=bias,
|
||||
padding_mode=padding_mode,
|
||||
)
|
||||
padding_mode = "constant" if padding_mode == "zeros" else padding_mode
|
||||
padding_mode_t = "constant" if padding_mode_t == "zeros" else padding_mode_t
|
||||
self.pad_mode = padding_mode
|
||||
self.pad_mode_t = padding_mode_t or ("constant" if causal else "replicate")
|
||||
self.causal = causal
|
||||
|
||||
def _apply_temporal_padding(self, x):
|
||||
B, C, D, H, W = x.shape
|
||||
if D > 1:
|
||||
pad_size = (
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
self.padding[0] * 2 if self.causal else self.padding[0],
|
||||
0 if self.causal else self.padding[0],
|
||||
)
|
||||
return F.pad(x, pad_size, mode=self.pad_mode_t)
|
||||
else:
|
||||
if self.pad_mode_t == "constant":
|
||||
assert self.causal, "Zeros padding is only supported for causal mode"
|
||||
return F.pad(
|
||||
x,
|
||||
(0, 0, 0, 0, self.kernel_size[0] - 1, 0),
|
||||
mode="constant",
|
||||
)
|
||||
else:
|
||||
return x.expand(-1, -1, self.kernel_size[0], -1, -1)
|
||||
|
||||
def _apply_padding(self, x):
|
||||
if sum(self.padding) == 0:
|
||||
return x
|
||||
|
||||
x = F.pad(
|
||||
x,
|
||||
(self.padding[2], self.padding[2], self.padding[1], self.padding[1], 0, 0),
|
||||
mode=self.pad_mode,
|
||||
)
|
||||
|
||||
x = self._apply_temporal_padding(x)
|
||||
return x
|
||||
|
||||
def forward(self, x):
|
||||
if sum(self.padding) == 0:
|
||||
return super().forward(x)
|
||||
|
||||
x = self._apply_padding(x)
|
||||
return F.conv3d(
|
||||
x,
|
||||
self.weight,
|
||||
self.bias,
|
||||
stride=self.stride,
|
||||
padding=0,
|
||||
dilation=self.dilation,
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Torch-native attention implemented with PyTorch SDPA instead of FA4/CUTLASS.
|
||||
import os
|
||||
from contextlib import nullcontext
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
_BLOCK_CAUSAL_MASK_MOD_CACHE = {}
|
||||
|
||||
|
||||
def _auto_sdpa_backend_name() -> str | None:
|
||||
"""Return the ROCm-only correctness fallback for H3 video-VAE SDPA."""
|
||||
if torch.version.hip is None:
|
||||
return None
|
||||
|
||||
from sglang.srt.utils import is_gfx95_supported
|
||||
|
||||
# Fused ROCm SDPA corrupts the dense ViT decode on gfx950. Keep every
|
||||
# non-gfx950 platform, including CUDA, on PyTorch's unchanged auto path.
|
||||
return "math" if is_gfx95_supported() else None
|
||||
|
||||
|
||||
_AUTO_SDPA_BACKEND = _auto_sdpa_backend_name()
|
||||
|
||||
|
||||
def _as_bool_mask(mask, *, device):
|
||||
if not isinstance(mask, torch.Tensor):
|
||||
mask = torch.as_tensor(mask, device=device)
|
||||
return mask.to(device=device, dtype=torch.bool)
|
||||
|
||||
|
||||
def _ensure_nonempty_rows(mask):
|
||||
if mask.numel() == 0 or mask.shape[-1] == 0:
|
||||
return mask
|
||||
empty = ~mask.any(dim=-1)
|
||||
mask[..., 0] |= empty
|
||||
return mask
|
||||
|
||||
|
||||
def _sdpa_kernel_context():
|
||||
backend_name = os.environ.get("MINIMAX_H3_TORCH_SDPA_BACKEND", "auto").lower()
|
||||
if backend_name in {"", "auto", "default"}:
|
||||
backend_name = _AUTO_SDPA_BACKEND
|
||||
if backend_name is None:
|
||||
return nullcontext()
|
||||
|
||||
from torch.nn.attention import SDPBackend, sdpa_kernel
|
||||
|
||||
backends = {
|
||||
"math": SDPBackend.MATH,
|
||||
"flash": SDPBackend.FLASH_ATTENTION,
|
||||
"flash_attention": SDPBackend.FLASH_ATTENTION,
|
||||
"efficient": SDPBackend.EFFICIENT_ATTENTION,
|
||||
"mem_efficient": SDPBackend.EFFICIENT_ATTENTION,
|
||||
"cudnn": SDPBackend.CUDNN_ATTENTION,
|
||||
"cudnn_attention": SDPBackend.CUDNN_ATTENTION,
|
||||
}
|
||||
if backend_name not in backends:
|
||||
raise ValueError(
|
||||
"MINIMAX_H3_TORCH_SDPA_BACKEND must be one of "
|
||||
f"{sorted([*backends, 'auto', 'default'])}, got {backend_name!r}"
|
||||
)
|
||||
return sdpa_kernel(backends=[backends[backend_name]])
|
||||
|
||||
|
||||
def _sdpa_attention(query, key, value, causal=False, attn_mask=None):
|
||||
# query/key/value arrive as [B, S, H, D]; PyTorch SDPA expects
|
||||
# [B, H, S, D].
|
||||
q = query.transpose(1, 2)
|
||||
k = key.transpose(1, 2)
|
||||
v = value.transpose(1, 2)
|
||||
if attn_mask is not None and attn_mask.dim() == 3:
|
||||
attn_mask = attn_mask.unsqueeze(0)
|
||||
with _sdpa_kernel_context():
|
||||
out = F.scaled_dot_product_attention(
|
||||
q,
|
||||
k,
|
||||
v,
|
||||
attn_mask=attn_mask,
|
||||
dropout_p=0.0,
|
||||
is_causal=causal,
|
||||
)
|
||||
return out.transpose(1, 2).nan_to_num(0.0)
|
||||
|
||||
|
||||
def _mask_mod_to_dense(mask_mod, batch, heads, q_len, kv_len, device, aux_tensors=None):
|
||||
q_idx = torch.arange(q_len, device=device).view(q_len, 1)
|
||||
kv_idx = torch.arange(kv_len, device=device).view(1, kv_len)
|
||||
dense = torch.empty((batch, heads, q_len, kv_len), dtype=torch.bool, device=device)
|
||||
for b in range(batch):
|
||||
b_idx = torch.tensor(b, device=device)
|
||||
for h in range(heads):
|
||||
h_idx = torch.tensor(h, device=device)
|
||||
mask = mask_mod(b_idx, h_idx, q_idx, kv_idx, None, aux_tensors)
|
||||
dense[b, h] = _as_bool_mask(mask, device=device)
|
||||
return _ensure_nonempty_rows(dense)
|
||||
|
||||
|
||||
#########################################################
|
||||
# Block causal attention
|
||||
#########################################################
|
||||
|
||||
|
||||
def make_block_causal_mask_mod(num_tokens, block_size, num_special=0, suffix=False):
|
||||
if num_tokens < 0:
|
||||
raise ValueError(f"num_tokens must be non-negative, got {num_tokens}")
|
||||
if block_size <= 0:
|
||||
raise ValueError(f"block_size must be positive, got {block_size}")
|
||||
if num_special < 0:
|
||||
raise ValueError(f"num_special must be non-negative, got {num_special}")
|
||||
|
||||
cache_key = (num_tokens, block_size, num_special, suffix)
|
||||
if cache_key in _BLOCK_CAUSAL_MASK_MOD_CACHE:
|
||||
return _BLOCK_CAUSAL_MASK_MOD_CACHE[cache_key]
|
||||
|
||||
if suffix:
|
||||
|
||||
def mask_mod(b, h, q_idx, kv_idx, seqlen_info, aux_tensors):
|
||||
del b, h, seqlen_info, aux_tensors
|
||||
q_is_special = q_idx >= num_tokens
|
||||
kv_is_special = kv_idx >= num_tokens
|
||||
return (
|
||||
q_is_special
|
||||
| kv_is_special
|
||||
| (q_idx // block_size >= kv_idx // block_size)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
def mask_mod(b, h, q_idx, kv_idx, seqlen_info, aux_tensors):
|
||||
del b, h, seqlen_info, aux_tensors
|
||||
q_is_special = q_idx < num_special
|
||||
kv_is_special = kv_idx < num_special
|
||||
q_block_idx = (q_idx - num_special) // block_size
|
||||
kv_block_idx = (kv_idx - num_special) // block_size
|
||||
return q_is_special | kv_is_special | (q_block_idx >= kv_block_idx)
|
||||
|
||||
mask_mod.block_sparse_cache_key = (
|
||||
"block_causal",
|
||||
num_tokens,
|
||||
block_size,
|
||||
num_special,
|
||||
suffix,
|
||||
)
|
||||
_BLOCK_CAUSAL_MASK_MOD_CACHE[cache_key] = mask_mod
|
||||
return mask_mod
|
||||
|
||||
|
||||
#########################################################
|
||||
# Public entry point
|
||||
#########################################################
|
||||
|
||||
|
||||
@torch.compiler.disable
|
||||
def flash_attn(
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
causal: bool = False,
|
||||
mask_mod=None,
|
||||
block_sparse=None,
|
||||
aux_tensors=None,
|
||||
) -> torch.Tensor:
|
||||
use_masked = mask_mod is not None or block_sparse is not None
|
||||
|
||||
if block_sparse is not None and mask_mod is None:
|
||||
raise ValueError("block_sparse requires mask_mod")
|
||||
if causal and mask_mod is not None:
|
||||
raise ValueError(
|
||||
"causal must be encoded in mask_mod when using masked attention"
|
||||
)
|
||||
if aux_tensors is not None and not use_masked:
|
||||
raise ValueError("aux_tensors is only supported with masked attention")
|
||||
|
||||
if use_masked:
|
||||
batch, q_len, heads, _ = query.shape
|
||||
kv_len = key.shape[1]
|
||||
dense_mask = _mask_mod_to_dense(
|
||||
mask_mod,
|
||||
batch,
|
||||
heads,
|
||||
q_len,
|
||||
kv_len,
|
||||
query.device,
|
||||
aux_tensors=aux_tensors,
|
||||
)
|
||||
return _sdpa_attention(query, key, value, attn_mask=dense_mask)
|
||||
|
||||
return _sdpa_attention(query, key, value, causal=causal)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Torch-native normalization for the MiniMax H3 visual VAE.
|
||||
import math
|
||||
import os
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from .conv import BaseConv3d
|
||||
|
||||
|
||||
def _validate_activation(activation):
|
||||
valid_activations = {"identity", "silu", "relu"}
|
||||
if activation not in valid_activations:
|
||||
raise ValueError(
|
||||
f"Unsupported activation: {activation}. Supported: {valid_activations}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_activation(x, activation):
|
||||
_validate_activation(activation)
|
||||
if activation == "identity":
|
||||
return x
|
||||
if activation == "silu":
|
||||
return F.silu(x)
|
||||
return F.relu(x)
|
||||
|
||||
|
||||
def _merge_time_to_batch(x):
|
||||
batch, channels, depth, height, width = x.shape
|
||||
return (
|
||||
x.permute(0, 2, 1, 3, 4)
|
||||
.contiguous()
|
||||
.view(batch * depth, channels, 1, height, width)
|
||||
)
|
||||
|
||||
|
||||
def _split_time_from_batch(x, batch):
|
||||
batch_depth, channels, _, height, width = x.shape
|
||||
depth = batch_depth // batch
|
||||
return (
|
||||
x.view(batch, depth, channels, height, width)
|
||||
.permute(0, 2, 1, 3, 4)
|
||||
.contiguous()
|
||||
)
|
||||
|
||||
|
||||
def fused_group_norm(x, num_groups, weight, bias, eps=1e-5, activation="silu"):
|
||||
out = F.group_norm(x, num_groups, weight=weight, bias=bias, eps=eps)
|
||||
return _apply_activation(out, activation)
|
||||
|
||||
|
||||
def fused_spatial_norm(
|
||||
f,
|
||||
num_groups,
|
||||
norm_weight,
|
||||
norm_bias,
|
||||
dynamic_scale,
|
||||
dynamic_bias,
|
||||
eps=1e-5,
|
||||
activation="silu",
|
||||
):
|
||||
norm_f = F.group_norm(
|
||||
f,
|
||||
num_groups,
|
||||
weight=norm_weight,
|
||||
bias=norm_bias,
|
||||
eps=eps,
|
||||
)
|
||||
out = norm_f * dynamic_scale + dynamic_bias
|
||||
return _apply_activation(out, activation)
|
||||
|
||||
|
||||
class DummyAffine(torch.nn.Module):
|
||||
def __init__(self, num_channels, affine=True):
|
||||
super().__init__()
|
||||
if affine:
|
||||
self.weight = torch.nn.Parameter(torch.ones(num_channels))
|
||||
self.bias = torch.nn.Parameter(torch.zeros(num_channels))
|
||||
else:
|
||||
self.register_parameter("weight", None)
|
||||
self.register_parameter("bias", None)
|
||||
|
||||
def forward(self, input):
|
||||
if self.weight is None:
|
||||
return input
|
||||
shape = [1, -1] + [1] * (input.dim() - 2)
|
||||
return input * self.weight.view(*shape) + self.bias.view(*shape)
|
||||
|
||||
|
||||
class FusedGroupNorm3D(torch.nn.Module):
|
||||
"""Compatibility wrapper implemented with native PyTorch ops."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_groups,
|
||||
num_channels,
|
||||
eps=1e-5,
|
||||
affine=True,
|
||||
activation="silu",
|
||||
cond_channels=None,
|
||||
use_t_isolated_gn=False,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
):
|
||||
super().__init__()
|
||||
_validate_activation(activation)
|
||||
self.num_groups = num_groups
|
||||
self.num_channels = num_channels
|
||||
self.eps = eps
|
||||
self.affine = affine
|
||||
self.activation = activation
|
||||
self.use_t_isolated_gn = use_t_isolated_gn
|
||||
|
||||
if cond_channels is not None:
|
||||
self.use_spatial_affine = True
|
||||
self.norm_layer = DummyAffine(num_channels, affine=affine)
|
||||
self.conv_y = BaseConv3d(
|
||||
cond_channels,
|
||||
num_channels,
|
||||
kernel_size=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
self.conv_b = BaseConv3d(
|
||||
cond_channels,
|
||||
num_channels,
|
||||
kernel_size=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
else:
|
||||
self.use_spatial_affine = False
|
||||
if self.affine:
|
||||
self.weight = torch.nn.Parameter(torch.ones(num_channels))
|
||||
self.bias = torch.nn.Parameter(torch.zeros(num_channels))
|
||||
else:
|
||||
self.register_parameter("weight", None)
|
||||
self.register_parameter("bias", None)
|
||||
|
||||
def forward(self, f, cond=None):
|
||||
need_reshape = self.use_t_isolated_gn and f.dim() == 5
|
||||
batch = f.shape[0] if need_reshape else None
|
||||
f_size = f.shape[-3:]
|
||||
if need_reshape:
|
||||
f = _merge_time_to_batch(f)
|
||||
|
||||
if self.use_spatial_affine:
|
||||
scale = self.conv_y(cond)
|
||||
bias = self.conv_b(cond)
|
||||
if math.prod(scale.shape[-3:]) * math.prod(bias.shape[-3:]) > 1:
|
||||
scale = F.interpolate(scale, size=f_size, mode="nearest")
|
||||
bias = F.interpolate(bias, size=f_size, mode="nearest")
|
||||
if need_reshape:
|
||||
scale = _merge_time_to_batch(scale)
|
||||
bias = _merge_time_to_batch(bias)
|
||||
out = fused_spatial_norm(
|
||||
f,
|
||||
self.num_groups,
|
||||
self.norm_layer.weight,
|
||||
self.norm_layer.bias,
|
||||
scale,
|
||||
bias,
|
||||
self.eps,
|
||||
self.activation,
|
||||
)
|
||||
else:
|
||||
if cond is not None:
|
||||
raise NotImplementedError("Dynamic affine is not defined")
|
||||
weight = self.weight if self.affine else None
|
||||
bias = self.bias if self.affine else None
|
||||
out = fused_group_norm(
|
||||
f, self.num_groups, weight, bias, self.eps, self.activation
|
||||
)
|
||||
|
||||
if need_reshape:
|
||||
out = _split_time_from_batch(out, batch)
|
||||
return out
|
||||
|
||||
|
||||
class TemporalIsolatedGroupNorm(nn.GroupNorm):
|
||||
def forward(self, input):
|
||||
if input.dim() == 5:
|
||||
batch = input.shape[0]
|
||||
input = _merge_time_to_batch(input)
|
||||
output = super().forward(input)
|
||||
return _split_time_from_batch(output, batch)
|
||||
return super().forward(input)
|
||||
|
||||
|
||||
class SpatialNorm3D(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
f_channels,
|
||||
zq_channels,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
use_t_isolated_gn=False,
|
||||
):
|
||||
super().__init__()
|
||||
norm_cls = TemporalIsolatedGroupNorm if use_t_isolated_gn else nn.GroupNorm
|
||||
self.norm_layer = norm_cls(
|
||||
num_groups=32, num_channels=f_channels, eps=1e-6, affine=True
|
||||
)
|
||||
|
||||
self.conv_y = BaseConv3d(
|
||||
zq_channels,
|
||||
f_channels,
|
||||
kernel_size=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
self.conv_b = BaseConv3d(
|
||||
zq_channels,
|
||||
f_channels,
|
||||
kernel_size=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
|
||||
def forward(self, f, zq):
|
||||
f_size = f.shape[-3:]
|
||||
norm_f = self.norm_layer(f)
|
||||
scale = self.conv_y(zq)
|
||||
bias = self.conv_b(zq)
|
||||
|
||||
if math.prod(scale.shape[-3:]) * math.prod(bias.shape[-3:]) > 1:
|
||||
scale = F.interpolate(scale, size=f_size, mode="nearest")
|
||||
bias = F.interpolate(bias, size=f_size, mode="nearest")
|
||||
|
||||
return norm_f * scale + bias
|
||||
|
||||
|
||||
def get_spatial_norm_3d(
|
||||
num_channels,
|
||||
cond_channels,
|
||||
*,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
use_t_isolated_gn=False,
|
||||
):
|
||||
if os.environ.get("MINIMAX_H3_USE_FUSED_NORM", "false").lower() == "true":
|
||||
return FusedGroupNorm3D(
|
||||
num_groups=32,
|
||||
num_channels=num_channels,
|
||||
eps=1e-6,
|
||||
affine=True,
|
||||
cond_channels=cond_channels,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
return SpatialNorm3D(
|
||||
num_channels,
|
||||
cond_channels,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
)
|
||||
|
||||
|
||||
def get_group_norm_3d(num_channels, use_t_isolated_gn=False):
|
||||
if os.environ.get("MINIMAX_H3_USE_FUSED_NORM", "false").lower() == "true":
|
||||
return FusedGroupNorm3D(
|
||||
num_groups=32,
|
||||
num_channels=num_channels,
|
||||
eps=1e-6,
|
||||
affine=True,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
)
|
||||
|
||||
norm_cls = TemporalIsolatedGroupNorm if use_t_isolated_gn else nn.GroupNorm
|
||||
return norm_cls(num_groups=32, num_channels=num_channels, eps=1e-6, affine=True)
|
||||
@@ -0,0 +1,279 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Tensor pre/post-processing for the MiniMax H3 visual VAE.
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from diffusers.utils import logging
|
||||
from einops import rearrange
|
||||
from torchvision.transforms import Normalize
|
||||
|
||||
NORM_CONFIGS = {
|
||||
"imagenet": {
|
||||
"mean": (0.485, 0.456, 0.406),
|
||||
"std": (0.229, 0.224, 0.225),
|
||||
},
|
||||
"simple": {
|
||||
"mean": (0.5, 0.5, 0.5),
|
||||
"std": (0.5, 0.5, 0.5),
|
||||
},
|
||||
"raw": {
|
||||
"mean": (0.0, 0.0, 0.0),
|
||||
"std": (1.0, 1.0, 1.0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_norm_constants(
|
||||
norm_type: str = "imagenet",
|
||||
) -> Tuple[Tuple[float, ...], Tuple[float, ...]]:
|
||||
if norm_type not in NORM_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown norm_type: {norm_type}. Must be one of {list(NORM_CONFIGS.keys())}"
|
||||
)
|
||||
config = NORM_CONFIGS[norm_type]
|
||||
return config["mean"], config["std"]
|
||||
|
||||
|
||||
def get_normalize_transform(
|
||||
norm_type: str = "imagenet", *, inplace: bool = False
|
||||
) -> Normalize:
|
||||
mean, std = get_norm_constants(norm_type)
|
||||
return Normalize(mean, std, inplace=inplace)
|
||||
|
||||
|
||||
def get_denormalize_transform(norm_type: str = "imagenet") -> Normalize:
|
||||
mean, std = get_norm_constants(norm_type)
|
||||
inv_mean = tuple(-m / s for m, s in zip(mean, std))
|
||||
inv_std = tuple(1.0 / s for s in std)
|
||||
return Normalize(inv_mean, inv_std)
|
||||
|
||||
|
||||
logger = logging.get_logger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class VAEProcessor:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vae_ratio,
|
||||
vae_ratio_t,
|
||||
clip_length,
|
||||
frame_overlap,
|
||||
token_overlap,
|
||||
tokens_chunk_size,
|
||||
isolated_last_frame,
|
||||
latent_patch_size,
|
||||
crop_mode,
|
||||
pixel_norm_type="imagenet",
|
||||
transform=None,
|
||||
transform_rev=None,
|
||||
use_3d_conv=False,
|
||||
):
|
||||
self.vae_ratio = vae_ratio
|
||||
self.vae_ratio_t = vae_ratio_t
|
||||
self.clip_length = clip_length
|
||||
self.frame_overlap = frame_overlap
|
||||
self.token_overlap = token_overlap
|
||||
self.tokens_chunk_size = tokens_chunk_size
|
||||
self.isolated_last_frame = isolated_last_frame
|
||||
self.latent_patch_size = latent_patch_size
|
||||
self.crop_mode = crop_mode
|
||||
self.transform = transform or get_normalize_transform(pixel_norm_type)
|
||||
self._runtime_owned_transform = (
|
||||
get_normalize_transform(pixel_norm_type, inplace=True)
|
||||
if transform is None
|
||||
else None
|
||||
)
|
||||
self.transform_rev = transform_rev or get_denormalize_transform(pixel_norm_type)
|
||||
self.use_3d_conv = use_3d_conv
|
||||
|
||||
def _ensure_list(self, data):
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
def _align_to_total_patch_size(self, h, w):
|
||||
total_patch_size = self.latent_patch_size * self.vae_ratio
|
||||
new_h = (h // total_patch_size) * total_patch_size
|
||||
new_w = (w // total_patch_size) * total_patch_size
|
||||
return new_h, new_w
|
||||
|
||||
def _crop_to_align(self, tensor, new_h, new_w, is_video=False):
|
||||
if is_video:
|
||||
_, _, _, h, w = tensor.shape
|
||||
else:
|
||||
_, _, h, w = tensor.shape
|
||||
|
||||
if self.crop_mode == "center":
|
||||
top = (h - new_h) // 2
|
||||
left = (w - new_w) // 2
|
||||
else:
|
||||
top = 0
|
||||
left = 0
|
||||
|
||||
if is_video:
|
||||
return tensor[:, :, :, top : top + new_h, left : left + new_w]
|
||||
else:
|
||||
return tensor[:, :, top : top + new_h, left : left + new_w]
|
||||
|
||||
def _align_target_token(self, T, mode):
|
||||
intra_tail = self.clip_length % self.vae_ratio_t
|
||||
min_frames = intra_tail or self.vae_ratio_t
|
||||
full_chunks = T // self.clip_length
|
||||
remainder = T % self.clip_length
|
||||
|
||||
if remainder == 0:
|
||||
return max(T, min_frames)
|
||||
|
||||
if mode == "pad":
|
||||
aligned_r = (
|
||||
math.ceil((remainder - intra_tail) / self.vae_ratio_t)
|
||||
* self.vae_ratio_t
|
||||
+ intra_tail
|
||||
)
|
||||
if aligned_r > self.clip_length:
|
||||
return (full_chunks + 1) * self.clip_length + intra_tail
|
||||
return full_chunks * self.clip_length + aligned_r
|
||||
else: # trim
|
||||
k = (remainder - intra_tail) // self.vae_ratio_t
|
||||
if k >= 0:
|
||||
target = (
|
||||
full_chunks * self.clip_length + k * self.vae_ratio_t + intra_tail
|
||||
)
|
||||
return max(target, min_frames)
|
||||
elif full_chunks > 0:
|
||||
return full_chunks * self.clip_length
|
||||
else:
|
||||
return min_frames
|
||||
|
||||
def _align_target(self, T, mode, granularity):
|
||||
if granularity == "chunk":
|
||||
step = self.clip_length
|
||||
tail = self.frame_overlap
|
||||
if self.isolated_last_frame:
|
||||
tail += 1
|
||||
|
||||
k = math.ceil((T - tail) / step) if mode == "pad" else (T - tail) // step
|
||||
return max(k, 1) * step + tail
|
||||
|
||||
isolated_extra = 1 if self.isolated_last_frame else 0
|
||||
return self._align_target_token(T - isolated_extra, mode) + isolated_extra
|
||||
|
||||
def align_video_length(self, video_length, mode="pad", granularity="chunk"):
|
||||
target = self._align_target(video_length, mode, granularity)
|
||||
delta = target - video_length
|
||||
if delta > 0 and mode == "trim":
|
||||
raise ValueError(
|
||||
f"Cannot trim {video_length} frames to valid length {target}: "
|
||||
f"not enough frames (granularity={granularity})"
|
||||
)
|
||||
return delta
|
||||
|
||||
def align_video_length_2pass(self, video_length):
|
||||
"""Return the leading/trailing frame pads and trailing latent drop.
|
||||
|
||||
This is the continuation-prefix (2-pass) alignment. The caller temporarily disables the model's normal token
|
||||
drop and keeps these mirrored processor fields at zero.
|
||||
"""
|
||||
if self.isolated_last_frame:
|
||||
raise ValueError(
|
||||
"align_video_length_2pass does not support isolated_last_frame"
|
||||
)
|
||||
if self.token_overlap != 0 or self.frame_overlap != 0:
|
||||
raise ValueError("align_video_length_2pass requires token_drop=0 alignment")
|
||||
|
||||
leading = self.align_video_length(video_length, mode="pad", granularity="token")
|
||||
token_aligned = video_length + leading
|
||||
trailing = self.align_video_length(
|
||||
token_aligned, mode="pad", granularity="chunk"
|
||||
)
|
||||
|
||||
if trailing > 0:
|
||||
intra_tail = self.clip_length % self.vae_ratio_t
|
||||
full_chunks = token_aligned // self.clip_length
|
||||
remainder = token_aligned % self.clip_length
|
||||
real_tokens = full_chunks * self.tokens_chunk_size
|
||||
if remainder > 0:
|
||||
real_tokens += (remainder - intra_tail) // self.vae_ratio_t + 1
|
||||
drop_tokens = self.get_latent_length(token_aligned + trailing) - real_tokens
|
||||
else:
|
||||
drop_tokens = 0
|
||||
|
||||
return leading, trailing, drop_tokens
|
||||
|
||||
def get_suitable_video_length(self, video_length, verbose=False):
|
||||
used_frame_length = video_length + self.align_video_length(
|
||||
video_length, mode="trim", granularity="chunk"
|
||||
)
|
||||
if verbose:
|
||||
logger.info(
|
||||
f"Pick first {used_frame_length} frames from {video_length}-frame video"
|
||||
)
|
||||
return used_frame_length
|
||||
|
||||
def get_latent_length(self, video_length):
|
||||
tail_frame = self.frame_overlap
|
||||
tail_token = self.token_overlap
|
||||
if self.isolated_last_frame:
|
||||
tail_frame += 1
|
||||
tail_token += 1
|
||||
|
||||
video_length = self.get_suitable_video_length(video_length)
|
||||
latent_length = (
|
||||
int((video_length - tail_frame) // self.clip_length)
|
||||
* self.tokens_chunk_size
|
||||
+ tail_token
|
||||
)
|
||||
return latent_length
|
||||
|
||||
def transform_tensor(self, tensor, *, runtime_owned=False):
|
||||
B, T = None, None
|
||||
if tensor.ndim == 5:
|
||||
if tensor.shape[2] == 3:
|
||||
tensor = tensor.transpose(1, 2)
|
||||
B, _, T, _, _ = tensor.shape
|
||||
tensor = rearrange(tensor, "b c t h w -> (b t) c h w")
|
||||
elif tensor.ndim == 4:
|
||||
if tensor.shape[0] == 3:
|
||||
tensor = tensor.transpose(0, 1)
|
||||
elif tensor.ndim == 3:
|
||||
tensor = tensor.unsqueeze(0)
|
||||
else:
|
||||
raise ValueError(f"Unsupported tensor shape: {tensor.shape}")
|
||||
|
||||
transform = (
|
||||
self._runtime_owned_transform
|
||||
if runtime_owned and self._runtime_owned_transform is not None
|
||||
else self.transform
|
||||
)
|
||||
tensor = transform(tensor)
|
||||
|
||||
if B is not None and T is not None:
|
||||
tensor = rearrange(tensor, "(b t) c h w -> b c t h w", b=B, t=T)
|
||||
|
||||
return tensor.contiguous()
|
||||
|
||||
def revert_tensor(self, tensor):
|
||||
B, T = None, None
|
||||
if self.use_3d_conv:
|
||||
tensor = tensor.unsqueeze(2) if tensor.ndim == 4 else tensor
|
||||
B, _, T, _, _ = tensor.shape
|
||||
tensor = rearrange(tensor, "b c t h w -> (b t) c h w")
|
||||
tensor_rev = self.transform_rev(tensor).clamp_(0, 1)
|
||||
if B is not None:
|
||||
tensor_rev = rearrange(tensor_rev, "(b t) c h w -> b c t h w", b=B, t=T)
|
||||
return tensor_rev.contiguous()
|
||||
|
||||
@staticmethod
|
||||
def convert_numpy_to_tensor(numpy_array, device=None):
|
||||
if isinstance(numpy_array, list):
|
||||
numpy_array = np.stack(numpy_array, axis=0)
|
||||
tensor = torch.from_numpy(numpy_array)
|
||||
# Keep decoded uint8 pixels compact across the host-to-device copy.
|
||||
# Casting the full video on CPU quadruples both the temporary host
|
||||
# allocation and transfer volume for no loss of information.
|
||||
if device is not None:
|
||||
tensor = tensor.to(device)
|
||||
tensor = tensor.permute(0, 3, 1, 2)
|
||||
return tensor.to(torch.float32).div_(255.0)
|
||||
@@ -0,0 +1,276 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# 3D causal CNN encoder for the MiniMax H3 visual VAE (inference-only bundle).
|
||||
import os
|
||||
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from .conv import BaseConv3d
|
||||
from .norm import get_group_norm_3d, get_spatial_norm_3d
|
||||
|
||||
# ============================================================================
|
||||
# 3D CNN Components
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def norm_silu(x, norm, cond=None):
|
||||
if cond is None:
|
||||
return F.silu(norm(x), inplace=True)
|
||||
else:
|
||||
return F.silu(norm(x, cond), inplace=True)
|
||||
|
||||
|
||||
class Downsample3D(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels,
|
||||
out_channels,
|
||||
time_stride=1,
|
||||
space_stride=2,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
):
|
||||
super().__init__()
|
||||
self.time_stride = time_stride
|
||||
self.space_stride = space_stride
|
||||
|
||||
assert time_stride in [1, 2]
|
||||
assert space_stride in [1, 2, 3]
|
||||
|
||||
self.conv = BaseConv3d(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
padding=(1, 0, 0),
|
||||
stride=(time_stride, space_stride, space_stride),
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
self.causal = self.conv.causal
|
||||
self.pad_mode = self.conv.pad_mode
|
||||
|
||||
def forward(self, x):
|
||||
if self.space_stride == 2:
|
||||
pad = (0, 1, 0, 1, 0, 0)
|
||||
x = F.pad(x, pad, mode=self.pad_mode)
|
||||
return self.conv(x)
|
||||
|
||||
|
||||
class ResnetBlock3D(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels,
|
||||
out_channels=None,
|
||||
zq_ch=None,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
use_t_isolated_gn=False,
|
||||
):
|
||||
super().__init__()
|
||||
self.in_channels = in_channels
|
||||
out_channels = in_channels if out_channels is None else out_channels
|
||||
self.out_channels = out_channels
|
||||
|
||||
self.use_fused_norm = (
|
||||
os.environ.get("MINIMAX_H3_USE_FUSED_NORM", "false").lower() == "true"
|
||||
)
|
||||
|
||||
if zq_ch is None:
|
||||
self.norm1 = get_group_norm_3d(
|
||||
in_channels, use_t_isolated_gn=use_t_isolated_gn
|
||||
)
|
||||
self.norm2 = get_group_norm_3d(
|
||||
out_channels, use_t_isolated_gn=use_t_isolated_gn
|
||||
)
|
||||
else:
|
||||
self.norm1 = get_spatial_norm_3d(
|
||||
in_channels,
|
||||
zq_ch,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
)
|
||||
self.norm2 = get_spatial_norm_3d(
|
||||
out_channels,
|
||||
zq_ch,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
)
|
||||
|
||||
self.conv1 = BaseConv3d(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
padding=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
|
||||
self.conv2 = BaseConv3d(
|
||||
out_channels,
|
||||
out_channels,
|
||||
kernel_size=3,
|
||||
padding=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
|
||||
if self.in_channels != self.out_channels:
|
||||
self.nin_shortcut = BaseConv3d(
|
||||
in_channels,
|
||||
out_channels,
|
||||
kernel_size=1,
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
|
||||
def forward(self, x, zq=None):
|
||||
h = x
|
||||
|
||||
if self.use_fused_norm:
|
||||
h = self.norm1(h, zq)
|
||||
else:
|
||||
h = norm_silu(h, self.norm1, zq)
|
||||
|
||||
h = self.conv1(h)
|
||||
|
||||
if self.use_fused_norm:
|
||||
h = self.norm2(h, zq)
|
||||
else:
|
||||
h = norm_silu(h, self.norm2, zq)
|
||||
|
||||
h = self.conv2(h)
|
||||
|
||||
if self.in_channels != self.out_channels:
|
||||
x = self.nin_shortcut(x)
|
||||
|
||||
return h.add_(x)
|
||||
|
||||
|
||||
class EncoderFCN3D(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
ch,
|
||||
ch_mult,
|
||||
space_down,
|
||||
time_down,
|
||||
num_res_blocks,
|
||||
in_channels,
|
||||
z_channels,
|
||||
double_z=False,
|
||||
zq_ch=None,
|
||||
padding_mode="zeros",
|
||||
padding_mode_t=None,
|
||||
causal=True,
|
||||
use_t_isolated_gn=False,
|
||||
):
|
||||
super().__init__()
|
||||
self.ch = ch
|
||||
self.num_levels = len(ch_mult)
|
||||
|
||||
if isinstance(num_res_blocks, int):
|
||||
self.num_res_blocks = [num_res_blocks] * self.num_levels
|
||||
else:
|
||||
self.num_res_blocks = num_res_blocks
|
||||
|
||||
self.space_down_factors = space_down
|
||||
self.time_down_factors = time_down
|
||||
self.in_channels = in_channels
|
||||
|
||||
self.use_fused_norm = (
|
||||
os.environ.get("MINIMAX_H3_USE_FUSED_NORM", "false").lower() == "true"
|
||||
)
|
||||
|
||||
block_mid = [ch * ch_mult[i] for i in range(self.num_levels)]
|
||||
block_in = [block_mid[0]] + block_mid[:-1]
|
||||
block_out = block_mid
|
||||
|
||||
conv_kwargs = dict(
|
||||
padding_mode=padding_mode,
|
||||
padding_mode_t=padding_mode_t,
|
||||
causal=causal,
|
||||
)
|
||||
|
||||
self.conv_in = BaseConv3d(
|
||||
in_channels, block_in[0], kernel_size=3, padding=1, **conv_kwargs
|
||||
)
|
||||
|
||||
self.down = nn.ModuleList()
|
||||
for i_level in range(self.num_levels):
|
||||
down = nn.Module()
|
||||
|
||||
down.block = nn.ModuleList()
|
||||
for i in range(self.num_res_blocks[i_level]):
|
||||
down.block.append(
|
||||
ResnetBlock3D(
|
||||
in_channels=block_in[i_level] if i == 0 else block_mid[i_level],
|
||||
out_channels=block_mid[i_level],
|
||||
zq_ch=zq_ch,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
**conv_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
if space_down[i_level] * time_down[i_level] > 1:
|
||||
down.downsample = Downsample3D(
|
||||
block_mid[i_level],
|
||||
block_out[i_level],
|
||||
time_stride=time_down[i_level],
|
||||
space_stride=space_down[i_level],
|
||||
**conv_kwargs,
|
||||
)
|
||||
else:
|
||||
if block_out[i_level] != block_mid[i_level]:
|
||||
down.downsample = BaseConv3d(
|
||||
block_mid[i_level],
|
||||
block_out[i_level],
|
||||
kernel_size=1,
|
||||
**conv_kwargs,
|
||||
)
|
||||
|
||||
self.down.append(down)
|
||||
|
||||
if zq_ch is None:
|
||||
self.norm_out = get_group_norm_3d(
|
||||
block_out[-1], use_t_isolated_gn=use_t_isolated_gn
|
||||
)
|
||||
else:
|
||||
self.norm_out = get_spatial_norm_3d(
|
||||
block_out[-1],
|
||||
zq_ch,
|
||||
use_t_isolated_gn=use_t_isolated_gn,
|
||||
**conv_kwargs,
|
||||
)
|
||||
|
||||
self.conv_out = BaseConv3d(
|
||||
block_out[-1],
|
||||
2 * z_channels if double_z else z_channels,
|
||||
kernel_size=3,
|
||||
padding=1,
|
||||
**conv_kwargs,
|
||||
)
|
||||
|
||||
def forward(self, x, zq=None):
|
||||
h = self.conv_in(x)
|
||||
for i_level in range(self.num_levels):
|
||||
for i_block in range(self.num_res_blocks[i_level]):
|
||||
h = self.down[i_level].block[i_block](h, zq)
|
||||
if hasattr(self.down[i_level], "downsample"):
|
||||
h = self.down[i_level].downsample(h)
|
||||
|
||||
if self.use_fused_norm:
|
||||
h = self.norm_out(h, zq)
|
||||
else:
|
||||
h = norm_silu(h, self.norm_out, zq)
|
||||
|
||||
h = self.conv_out(h)
|
||||
return h
|
||||
@@ -0,0 +1,374 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# ViT3D decoder for the MiniMax H3 visual VAE (inference-only bundle).
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
from diffusers.configuration_utils import ConfigMixin, register_to_config
|
||||
from diffusers.models.modeling_utils import ModelMixin
|
||||
from diffusers.utils import logging
|
||||
|
||||
from .base_module import RotaryEmbeddingND, TransformerBlock
|
||||
from .flash import make_block_causal_mask_mod
|
||||
from .vit_utils import create_token_ids, prepare_rotary_pos_emb
|
||||
|
||||
logger = logging.get_logger(__name__)
|
||||
|
||||
|
||||
def _linear_with_module_dtype(linear, tensor, out_dtype=None):
|
||||
weight = getattr(linear, "weight", None)
|
||||
target_dtype = getattr(weight, "dtype", tensor.dtype)
|
||||
output = linear(tensor.to(target_dtype))
|
||||
if out_dtype is not None and output.dtype != out_dtype:
|
||||
output = output.to(out_dtype)
|
||||
return output
|
||||
|
||||
|
||||
def _pack_tensors_3d(tensors, patch_size, patch_size_t):
|
||||
batch_size, num_channels_tensors, temporal, height, width = tensors.shape
|
||||
|
||||
tensors = tensors.view(
|
||||
batch_size,
|
||||
num_channels_tensors,
|
||||
temporal // patch_size_t,
|
||||
patch_size_t,
|
||||
height // patch_size,
|
||||
patch_size,
|
||||
width // patch_size,
|
||||
patch_size,
|
||||
)
|
||||
tensors = tensors.permute(0, 2, 4, 6, 1, 3, 5, 7)
|
||||
tensors = tensors.reshape(
|
||||
batch_size,
|
||||
(temporal // patch_size_t) * (height // patch_size) * (width // patch_size),
|
||||
num_channels_tensors * patch_size_t * patch_size * patch_size,
|
||||
)
|
||||
return tensors
|
||||
|
||||
|
||||
def _unpack_tensors_3d(tensors, patch_size, patch_size_t, temporal, height, width):
|
||||
batch_size, num_patches, channels = tensors.shape
|
||||
num_channels_tensors = channels // (patch_size_t * patch_size * patch_size)
|
||||
|
||||
tensors = tensors.view(
|
||||
batch_size,
|
||||
temporal // patch_size_t,
|
||||
height // patch_size,
|
||||
width // patch_size,
|
||||
num_channels_tensors,
|
||||
patch_size_t,
|
||||
patch_size,
|
||||
patch_size,
|
||||
)
|
||||
tensors = tensors.permute(0, 4, 1, 5, 2, 6, 3, 7).contiguous()
|
||||
tensors = tensors.reshape(batch_size, num_channels_tensors, temporal, height, width)
|
||||
return tensors
|
||||
|
||||
|
||||
class ViTBase(ModelMixin, ConfigMixin):
|
||||
"""Base class for ViT Encoder and Decoder with common functionality."""
|
||||
|
||||
_no_split_modules = ["TransformerBlock"]
|
||||
|
||||
def _init_weights(self):
|
||||
def basic_init(m):
|
||||
if isinstance(m, nn.Linear):
|
||||
nn.init.xavier_uniform_(m.weight)
|
||||
if m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
|
||||
self.apply(basic_init)
|
||||
|
||||
def init_mask_config(self, dim, is_3d=False):
|
||||
self._mask_dim = dim
|
||||
self._mask_is_3d = is_3d
|
||||
self.register_buffer("mask_token", torch.zeros(1, 1, dim))
|
||||
|
||||
def set_mask_config(self, mask_config):
|
||||
self.mask_prob = mask_config.get("mask_prob", 0.0)
|
||||
self.mask_enabled = self.mask_prob > 0
|
||||
self.mask_style = mask_config.get("mask_style", "replace")
|
||||
if self.mask_enabled and self.mask_style == "drop" and self.mask_prob < 1.0:
|
||||
logger.warning("mask_style='drop' with mask_prob < 1.0")
|
||||
if self._mask_is_3d:
|
||||
self.temporal_scale_range = mask_config.get(
|
||||
"temporal_scale_range", (0.3, 0.5)
|
||||
)
|
||||
self.spatial_scale_range = mask_config.get(
|
||||
"spatial_scale_range", (0.1, 0.25)
|
||||
)
|
||||
self.min_mask_ratio = mask_config.get("min_mask_ratio", 0.75)
|
||||
self.max_mask_ratio = mask_config.get("max_mask_ratio", 0.95)
|
||||
else:
|
||||
self.spatial_scale_range = mask_config.get(
|
||||
"spatial_scale_range", (0.15, 0.15)
|
||||
)
|
||||
self.min_mask_ratio = mask_config.get("min_mask_ratio", 0.5)
|
||||
self.max_mask_ratio = mask_config.get("max_mask_ratio", 0.75)
|
||||
self.aspect_ratio_range = mask_config.get("aspect_ratio_range", (0.75, 1.5))
|
||||
self.max_retries = mask_config.get("max_retries", 100)
|
||||
if (
|
||||
self.mask_enabled
|
||||
and self.mask_style == "drop"
|
||||
and getattr(self, "t_causal", False)
|
||||
):
|
||||
logger.warning("mask_style='drop' with t_causal may cause issues")
|
||||
if self.mask_enabled and "mask_token" in self._buffers:
|
||||
del self._buffers["mask_token"]
|
||||
self.mask_token = nn.Parameter(torch.randn(1, 1, self._mask_dim) * 0.02)
|
||||
|
||||
def init_suffix_tokens(self, dim, num_register_tokens, has_cls_token=True):
|
||||
self.num_register_tokens = num_register_tokens
|
||||
if num_register_tokens > 0:
|
||||
self.register_tokens = nn.Parameter(
|
||||
torch.randn(1, num_register_tokens, dim) * 0.02
|
||||
)
|
||||
else:
|
||||
self.register_tokens = None
|
||||
if has_cls_token:
|
||||
self.cls_token = nn.Parameter(torch.randn(1, 1, dim) * 0.02)
|
||||
|
||||
def apply_mask_preprocess(self, hidden_states, img_ids, patch_dims, num_suffix):
|
||||
if self.training and self.mask_enabled:
|
||||
raise NotImplementedError(
|
||||
"mask modeling is not supported in this inference-only bundle"
|
||||
)
|
||||
return hidden_states, img_ids
|
||||
|
||||
def forward_transformer_blocks(self, hidden_states, rotary_pos_emb, pack_info=None):
|
||||
if pack_info is None:
|
||||
pack_info = {}
|
||||
for block in self.transformer_blocks:
|
||||
hidden_states = block(hidden_states, rotary_pos_emb, pack_info)
|
||||
return hidden_states
|
||||
|
||||
def apply_mask_postprocess(self, hidden_states, num_patches):
|
||||
if self.training and self.mask_enabled and self.mask_style == "drop":
|
||||
raise NotImplementedError(
|
||||
"mask modeling is not supported in this inference-only bundle"
|
||||
)
|
||||
return hidden_states
|
||||
|
||||
|
||||
class ViT3DDecoder(ViTBase):
|
||||
"""Vision Transformer Video Decoder using TransformerBlock."""
|
||||
|
||||
@register_to_config
|
||||
def __init__(
|
||||
self,
|
||||
patch_size: int = 16,
|
||||
patch_size_t: int = 4,
|
||||
t_causal: bool = False,
|
||||
in_channels: int = 16,
|
||||
out_channels: int = 3,
|
||||
num_layers: int = 24,
|
||||
heads: int = 16,
|
||||
dim_head: int = 64,
|
||||
norm_type: str = "layer_norm",
|
||||
norm_affine: bool = True,
|
||||
qk_norm_type: str = None,
|
||||
qk_norm_affine: bool = False,
|
||||
ffn_activation_fn: str = "gelu",
|
||||
ffn_use_gated: bool = False,
|
||||
rope_theta: float = 100.0,
|
||||
rope_dim_ratio: float = 1.0,
|
||||
bias: bool = True,
|
||||
eps: float = 1e-5,
|
||||
num_register_tokens: int = 4,
|
||||
mask_config: dict = {},
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
dim = heads * dim_head
|
||||
rope_apply_dim = int(dim_head * rope_dim_ratio)
|
||||
|
||||
self.pos_embed = RotaryEmbeddingND(
|
||||
rope_apply_dim, rope_theta, n_dim=3, use_angle=True
|
||||
)
|
||||
|
||||
self.x_embedder = nn.Linear(in_channels, dim)
|
||||
|
||||
self.init_suffix_tokens(dim, num_register_tokens, has_cls_token=False)
|
||||
|
||||
self.t_causal = t_causal
|
||||
|
||||
self.transformer_blocks = nn.ModuleList(
|
||||
[
|
||||
TransformerBlock(
|
||||
heads=heads,
|
||||
dim_head=dim_head,
|
||||
norm_type=norm_type,
|
||||
norm_affine=norm_affine,
|
||||
qk_norm_type=qk_norm_type,
|
||||
qk_norm_affine=qk_norm_affine,
|
||||
ffn_activation_fn=ffn_activation_fn,
|
||||
ffn_use_gated=ffn_use_gated,
|
||||
bias=bias,
|
||||
eps=eps,
|
||||
**kwargs,
|
||||
)
|
||||
for _ in range(num_layers)
|
||||
]
|
||||
)
|
||||
|
||||
self.norm_out = nn.LayerNorm(dim, elementwise_affine=norm_affine, eps=eps)
|
||||
patch_dim = out_channels * patch_size_t * patch_size * patch_size
|
||||
self.proj_out = nn.Linear(dim, patch_dim)
|
||||
|
||||
self.init_mask_config(dim, is_3d=True)
|
||||
self.set_mask_config(mask_config)
|
||||
|
||||
self._rotary_pos_emb_cache = None
|
||||
self._autocast_linear_dtype = None
|
||||
|
||||
if len(kwargs) > 0 and (not dist.is_initialized() or dist.get_rank() == 0):
|
||||
logger.warning(f"Unused kwargs: {kwargs}")
|
||||
|
||||
def _apply(self, fn, recurse=True):
|
||||
result = super()._apply(fn, recurse=recurse)
|
||||
self._rotary_pos_emb_cache = None
|
||||
self._autocast_linear_dtype = None
|
||||
return result
|
||||
|
||||
def prepare_autocast_linear_weights(self, dtype: torch.dtype) -> int:
|
||||
"""Keep decoder-block linear weights in their autocast compute dtype.
|
||||
|
||||
PyTorch autocast does not cache casts for these frozen parameters, so
|
||||
tiled decode otherwise converts every FP32 weight and bias once per
|
||||
block invocation. Persisting the rounded values is numerically
|
||||
equivalent to the per-call autocast conversion. The embedding and
|
||||
output projections stay FP32 because their calls explicitly disable
|
||||
autocast.
|
||||
"""
|
||||
|
||||
if dtype not in (torch.float16, torch.bfloat16):
|
||||
raise ValueError(
|
||||
"MiniMax H3 decoder autocast weights require fp16 or bf16, "
|
||||
f"got {dtype}"
|
||||
)
|
||||
if self._autocast_linear_dtype == dtype:
|
||||
return 0
|
||||
|
||||
converted = 0
|
||||
for block in self.transformer_blocks:
|
||||
for linear in (
|
||||
block.attn.to_qkv,
|
||||
block.attn.to_out,
|
||||
block.ff.w1,
|
||||
block.ff.w2,
|
||||
):
|
||||
if linear.weight.dtype != dtype:
|
||||
linear.to(dtype=dtype)
|
||||
converted += 1
|
||||
self._autocast_linear_dtype = dtype
|
||||
return converted
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
B, C, latent_T, latent_H, latent_W = x.shape
|
||||
patch_size = self.config.patch_size
|
||||
patch_size_t = self.config.patch_size_t
|
||||
num_suffix = 1 + self.num_register_tokens
|
||||
|
||||
hidden_states = _pack_tensors_3d(x, 1, 1)
|
||||
latent_size = (latent_T, latent_H, latent_W)
|
||||
|
||||
with torch.autocast("cuda", enabled=False):
|
||||
hidden_states = _linear_with_module_dtype(
|
||||
self.x_embedder, hidden_states, hidden_states.dtype
|
||||
)
|
||||
|
||||
num_patches = hidden_states.shape[1]
|
||||
|
||||
tokens = [hidden_states]
|
||||
|
||||
if self.register_tokens is not None:
|
||||
register_tokens = self.register_tokens.expand(B, -1, -1)
|
||||
tokens.append(register_tokens)
|
||||
|
||||
cls_token = torch.zeros_like(hidden_states[:, 0:1, :])
|
||||
tokens.append(cls_token)
|
||||
hidden_states = torch.cat(tokens, dim=1)
|
||||
|
||||
patch_dims = [latent_T, latent_H, latent_W]
|
||||
rotary_dtype = (
|
||||
torch.get_autocast_dtype("cuda")
|
||||
if x.is_cuda and torch.is_autocast_enabled("cuda")
|
||||
else hidden_states.dtype
|
||||
)
|
||||
cache_enabled = (
|
||||
not self.training
|
||||
and not self.mask_enabled
|
||||
and not torch.compiler.is_compiling()
|
||||
)
|
||||
cache_key = (
|
||||
B,
|
||||
latent_T,
|
||||
latent_H,
|
||||
latent_W,
|
||||
num_suffix,
|
||||
x.device,
|
||||
x.dtype,
|
||||
rotary_dtype,
|
||||
)
|
||||
cache_record = self._rotary_pos_emb_cache if cache_enabled else None
|
||||
cache_hit = cache_record is not None and cache_record[0] == cache_key
|
||||
if cache_hit:
|
||||
img_ids = cache_record[1]
|
||||
else:
|
||||
img_ids = create_token_ids(latent_size, x.device, x.dtype).expand(B, -1, -1)
|
||||
suffix_ids = torch.zeros(
|
||||
(B, num_suffix, 3), device=x.device, dtype=img_ids.dtype
|
||||
)
|
||||
img_ids = torch.cat([img_ids, suffix_ids], dim=1)
|
||||
|
||||
hidden_states, img_ids = self.apply_mask_preprocess(
|
||||
hidden_states, img_ids, patch_dims, num_suffix
|
||||
)
|
||||
cache_img_ids = img_ids
|
||||
|
||||
pack_info = {}
|
||||
if self.t_causal:
|
||||
spatial_size = latent_H * latent_W
|
||||
mask_mod = make_block_causal_mask_mod(
|
||||
num_tokens=num_patches,
|
||||
block_size=spatial_size,
|
||||
suffix=True,
|
||||
)
|
||||
pack_info["mask_mod"] = mask_mod
|
||||
|
||||
if cache_hit:
|
||||
rotary_pos_emb = cache_record[2]
|
||||
else:
|
||||
rotary_pos_emb = prepare_rotary_pos_emb(
|
||||
self.pos_embed(img_ids),
|
||||
dtype=rotary_dtype,
|
||||
)
|
||||
if cache_enabled:
|
||||
self._rotary_pos_emb_cache = (
|
||||
cache_key,
|
||||
cache_img_ids,
|
||||
rotary_pos_emb,
|
||||
)
|
||||
|
||||
for block in self.transformer_blocks:
|
||||
hidden_states = block(hidden_states, rotary_pos_emb, pack_info)
|
||||
|
||||
hidden_states = self.norm_out(hidden_states)
|
||||
|
||||
hidden_states = self.apply_mask_postprocess(hidden_states, num_patches)
|
||||
|
||||
with torch.autocast("cuda", enabled=False):
|
||||
output = _linear_with_module_dtype(
|
||||
self.proj_out, hidden_states, hidden_states.dtype
|
||||
)
|
||||
|
||||
output = output[:, :num_patches, :]
|
||||
|
||||
video_t = latent_size[0] * patch_size_t
|
||||
video_h = latent_size[1] * patch_size
|
||||
video_w = latent_size[2] * patch_size
|
||||
output = _unpack_tensors_3d(
|
||||
output, patch_size, patch_size_t, video_t, video_h, video_w
|
||||
)
|
||||
|
||||
return output
|
||||
@@ -0,0 +1,255 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# ViT runtime helpers for the MiniMax H3 visual VAE.
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Tuple
|
||||
|
||||
import torch
|
||||
from diffusers.utils import logging
|
||||
|
||||
|
||||
def _env_flag(name, default="0"):
|
||||
value = os.environ.get(name, default)
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_optional_bool(name, default=""):
|
||||
value = str(os.environ.get(name, default)).strip().lower()
|
||||
if value in ("", "default", "auto", "none", "unset"):
|
||||
return None
|
||||
return value not in ("0", "false", "no", "off", "disabled")
|
||||
|
||||
|
||||
def _vit_torch_compile_kwargs(prefix):
|
||||
kwargs = {}
|
||||
backend = os.environ.get(f"{prefix}_BACKEND", "inductor").strip()
|
||||
mode = os.environ.get(f"{prefix}_MODE", "reduce-overhead").strip()
|
||||
if backend and backend.lower() not in ("default", "none"):
|
||||
kwargs["backend"] = backend
|
||||
if mode and mode.lower() not in ("default", "none"):
|
||||
kwargs["mode"] = mode
|
||||
kwargs["fullgraph"] = _env_flag(f"{prefix}_FULLGRAPH", "0")
|
||||
dynamic = _env_optional_bool(f"{prefix}_DYNAMIC")
|
||||
if dynamic is not None:
|
||||
kwargs["dynamic"] = dynamic
|
||||
return kwargs
|
||||
|
||||
|
||||
logger = logging.get_logger(__name__) # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def create_token_ids(
|
||||
patch_dims, device, dtype, id_type="length_normalized", flatten=True
|
||||
):
|
||||
coords_list = []
|
||||
|
||||
if isinstance(id_type, str):
|
||||
id_type_list = [id_type] * len(patch_dims)
|
||||
elif isinstance(id_type, list):
|
||||
id_type_list = id_type
|
||||
if len(id_type_list) != len(patch_dims):
|
||||
raise ValueError("id_type list must match patch_dims")
|
||||
else:
|
||||
raise ValueError("id_type must be a string or a list")
|
||||
|
||||
if "area_normalized" in id_type_list or id_type == "area_normalized":
|
||||
raise NotImplementedError(
|
||||
"area_normalized id_type is not supported in this inference-only bundle"
|
||||
)
|
||||
|
||||
for _dim_size, _id_type in zip(patch_dims, id_type_list):
|
||||
if isinstance(_dim_size, torch.Tensor):
|
||||
coords_list.append(_dim_size.to(device=device, dtype=dtype))
|
||||
continue
|
||||
|
||||
if _id_type == "length_normalized":
|
||||
coords = torch.arange(0.5, _dim_size, dtype=dtype, device=device)
|
||||
coords = coords / _dim_size
|
||||
coords = 2.0 * coords - 1.0
|
||||
else:
|
||||
coords = torch.arange(_dim_size, dtype=dtype, device=device)
|
||||
|
||||
coords_list.append(coords)
|
||||
|
||||
coords = torch.stack(torch.meshgrid(*coords_list, indexing="ij"), dim=-1)
|
||||
if flatten:
|
||||
coords = coords.flatten(0, len(patch_dims) - 1)
|
||||
|
||||
return coords.unsqueeze(0)
|
||||
|
||||
|
||||
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
|
||||
x1, x2 = torch.chunk(x, 2, dim=-1)
|
||||
return torch.cat((-x2, x1), dim=-1)
|
||||
|
||||
|
||||
def _apply_rotary_pos_emb_impl(
|
||||
t: torch.Tensor, rotary_pos_emb: Tuple[torch.Tensor, torch.Tensor]
|
||||
) -> torch.Tensor:
|
||||
cos, sin = rotary_pos_emb[:2]
|
||||
|
||||
if cos.dim() != 4:
|
||||
raise ValueError(f"cos must be [B, N, 1, D], got {cos.shape}")
|
||||
|
||||
cos = cos.to(t.dtype)
|
||||
sin = sin.to(t.dtype)
|
||||
|
||||
rot_dim = cos.shape[-1]
|
||||
t_dim = t.shape[-1]
|
||||
|
||||
if rot_dim < t_dim:
|
||||
t_rot, t_pass = t[..., :rot_dim], t[..., rot_dim:]
|
||||
scaled = t_rot * cos
|
||||
scaled.add_(_rotate_half(t_rot) * sin)
|
||||
t_rot = scaled
|
||||
t = torch.cat((t_rot, t_pass), dim=-1)
|
||||
else:
|
||||
scaled = t * cos
|
||||
scaled.add_(_rotate_half(t) * sin)
|
||||
t = scaled
|
||||
|
||||
return t
|
||||
|
||||
|
||||
def prepare_rotary_pos_emb(
|
||||
rotary_pos_emb: Tuple[torch.Tensor, torch.Tensor],
|
||||
*,
|
||||
dtype: torch.dtype,
|
||||
) -> tuple[torch.Tensor, ...]:
|
||||
"""Prebuild the native Q/K rotary cache once per ViT decoder forward."""
|
||||
cos, sin = rotary_pos_emb
|
||||
if (
|
||||
not cos.is_cuda
|
||||
or dtype not in (torch.float16, torch.bfloat16)
|
||||
or cos.shape != sin.shape
|
||||
or cos.dim() != 4
|
||||
or cos.shape[0] != 1
|
||||
or cos.shape[2] != 1
|
||||
or cos.shape[-1] % 2
|
||||
or _env_flag("MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE", "0")
|
||||
):
|
||||
return cos, sin
|
||||
|
||||
cos = cos.to(dtype=dtype)
|
||||
sin = sin.to(dtype=dtype)
|
||||
half = cos.shape[-1] // 2
|
||||
# RotaryEmbeddingND repeats each half. The native kernel consumes the
|
||||
# compact NeoX cache [cos_half | sin_half].
|
||||
cache = torch.cat(
|
||||
(cos[0, :, 0, :half], sin[0, :, 0, :half]),
|
||||
dim=-1,
|
||||
).contiguous()
|
||||
positions = torch.arange(
|
||||
cos.shape[1],
|
||||
dtype=torch.long,
|
||||
device=cos.device,
|
||||
)
|
||||
return cos, sin, cache, positions
|
||||
|
||||
|
||||
_COMPILED_APPLY_ROTARY_POS_EMB = None
|
||||
_APPLY_ROTARY_POS_EMB_COMPILE_DISABLED = False
|
||||
|
||||
|
||||
def _get_apply_rotary_pos_emb_impl():
|
||||
global _COMPILED_APPLY_ROTARY_POS_EMB, _APPLY_ROTARY_POS_EMB_COMPILE_DISABLED
|
||||
if _APPLY_ROTARY_POS_EMB_COMPILE_DISABLED or not _env_flag(
|
||||
"MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE", "0"
|
||||
):
|
||||
return _apply_rotary_pos_emb_impl
|
||||
if _COMPILED_APPLY_ROTARY_POS_EMB is not None:
|
||||
return _COMPILED_APPLY_ROTARY_POS_EMB
|
||||
if not hasattr(torch, "compile"):
|
||||
message = (
|
||||
"torch.compile is unavailable; falling back to eager ViT rotary embedding"
|
||||
)
|
||||
if _env_flag("MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE_FATAL", "0"):
|
||||
raise RuntimeError(message)
|
||||
logger.warning(f"[ViTRope] {message}")
|
||||
_APPLY_ROTARY_POS_EMB_COMPILE_DISABLED = True
|
||||
return _apply_rotary_pos_emb_impl
|
||||
|
||||
kwargs = _vit_torch_compile_kwargs("MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE")
|
||||
try:
|
||||
_COMPILED_APPLY_ROTARY_POS_EMB = torch.compile(
|
||||
_apply_rotary_pos_emb_impl, **kwargs
|
||||
)
|
||||
logger.info(f"[ViTRope] torch.compile enabled kwargs={kwargs}")
|
||||
except Exception as exc:
|
||||
if _env_flag("MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE_FATAL", "0"):
|
||||
raise
|
||||
logger.warning(
|
||||
f"[ViTRope] torch.compile setup failed: {type(exc).__name__}: {exc}; "
|
||||
"falling back to eager"
|
||||
)
|
||||
_APPLY_ROTARY_POS_EMB_COMPILE_DISABLED = True
|
||||
_COMPILED_APPLY_ROTARY_POS_EMB = None
|
||||
return _apply_rotary_pos_emb_impl
|
||||
return _COMPILED_APPLY_ROTARY_POS_EMB
|
||||
|
||||
|
||||
def apply_rotary_pos_emb(
|
||||
t: torch.Tensor, rotary_pos_emb: Sequence[torch.Tensor]
|
||||
) -> torch.Tensor:
|
||||
global _COMPILED_APPLY_ROTARY_POS_EMB, _APPLY_ROTARY_POS_EMB_COMPILE_DISABLED
|
||||
fn = _get_apply_rotary_pos_emb_impl()
|
||||
try:
|
||||
return fn(t, rotary_pos_emb)
|
||||
except Exception as exc:
|
||||
if fn is _COMPILED_APPLY_ROTARY_POS_EMB and not _env_flag(
|
||||
"MINIMAX_H3_VAE_DECODER_VIT_ROPE_TORCH_COMPILE_FATAL", "0"
|
||||
):
|
||||
logger.warning(
|
||||
f"[ViTRope] compiled call failed: {type(exc).__name__}: {exc}; "
|
||||
"disabling compile and retrying eager"
|
||||
)
|
||||
_APPLY_ROTARY_POS_EMB_COMPILE_DISABLED = True
|
||||
_COMPILED_APPLY_ROTARY_POS_EMB = None
|
||||
return _apply_rotary_pos_emb_impl(t, rotary_pos_emb)
|
||||
raise
|
||||
|
||||
|
||||
def apply_rotary_pos_emb_qk(
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
rotary_pos_emb: Sequence[torch.Tensor],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Apply the exact native NeoX rotary kernel to Q/K together when possible."""
|
||||
if (
|
||||
len(rotary_pos_emb) == 4
|
||||
and query.is_cuda
|
||||
and query.shape == key.shape
|
||||
and query.dtype == key.dtype
|
||||
and query.dtype in (torch.float16, torch.bfloat16)
|
||||
and query.dim() == 4
|
||||
and query.shape[0] == 1
|
||||
and not torch.compiler.is_compiling()
|
||||
):
|
||||
_, _, cache, positions = rotary_pos_emb
|
||||
if (
|
||||
cache.is_cuda
|
||||
and cache.dtype == query.dtype
|
||||
and cache.dim() == 2
|
||||
and cache.shape[0] == query.shape[1]
|
||||
and cache.shape[1] <= query.shape[-1]
|
||||
and positions.is_cuda
|
||||
and positions.shape == (query.shape[1],)
|
||||
):
|
||||
from sgl_kernel import rotary_embedding
|
||||
|
||||
query = query.contiguous()
|
||||
key = key.contiguous()
|
||||
rotary_embedding(
|
||||
positions,
|
||||
query.view(query.shape[1], -1),
|
||||
key.view(key.shape[1], -1),
|
||||
query.shape[-1],
|
||||
cache,
|
||||
True,
|
||||
)
|
||||
return query, key
|
||||
|
||||
return (
|
||||
apply_rotary_pos_emb(query, rotary_pos_emb),
|
||||
apply_rotary_pos_emb(key, rotary_pos_emb),
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from sglang.multimodal_gen.configs.pipeline_configs.minimax_h3 import (
|
||||
MiniMaxH3PipelineConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.sample.minimax_h3 import MiniMaxH3SamplingParams
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.composed_pipeline_base import (
|
||||
ComposedPipelineBase,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.lora_pipeline import LoRAPipeline
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages import InputValidationStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3 import (
|
||||
MiniMaxH3AudioEncodingStage,
|
||||
MiniMaxH3DecodingStage,
|
||||
MiniMaxH3DenoisingStage,
|
||||
MiniMaxH3LatentPreparationStage,
|
||||
MiniMaxH3TextEncodingStage,
|
||||
MiniMaxH3TimestepPreparationStage,
|
||||
MiniMaxH3VisualEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.release_metadata import (
|
||||
MiniMaxH3PartitionAdmissionStage,
|
||||
MiniMaxH3ReleaseMetadata,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class MiniMaxH3Pipeline(LoRAPipeline, ComposedPipelineBase):
|
||||
pipeline_name = "MiniMaxH3Pipeline"
|
||||
default_model_subfolder = "FL2VA"
|
||||
is_video_pipeline = True
|
||||
pipeline_config_cls = MiniMaxH3PipelineConfig
|
||||
sampling_params_cls = MiniMaxH3SamplingParams
|
||||
_required_config_modules = [
|
||||
"processor",
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"video_vae",
|
||||
"audio_vae",
|
||||
# scheduler intentionally absent: model_index carries scheduler=null;
|
||||
# per-modality sigma schedules are generated in TimestepPreparation
|
||||
# from the task profile, and the loop scheduler math lives in
|
||||
# scheduling_minimax_h3_euler_ancestral (stages accept scheduler=None).
|
||||
"transformer",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def model_subfolder_for_variant(variant: str) -> str:
|
||||
if not isinstance(variant, str) or not variant.strip():
|
||||
raise ValueError("MiniMax H3 model variant must be a non-empty string")
|
||||
normalized = variant.strip().lower()
|
||||
subfolders = {
|
||||
"fl2va": "FL2VA",
|
||||
"ref2va": "Ref2VA",
|
||||
}
|
||||
try:
|
||||
return subfolders[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unsupported MiniMax H3 model variant {variant!r}; "
|
||||
f"supported: {sorted(subfolders)!r}"
|
||||
) from exc
|
||||
|
||||
def _load_config(self):
|
||||
model_variant = self.server_args.model_variant
|
||||
if model_variant is not None:
|
||||
semantic_subfolder = self.model_subfolder_for_variant(model_variant)
|
||||
explicit_subfolder = self.server_args.model_subfolder
|
||||
if (
|
||||
explicit_subfolder is not None
|
||||
and explicit_subfolder.strip().lower() != semantic_subfolder.lower()
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 --model-variant and --model-subfolder select "
|
||||
f"different weight partitions: variant={model_variant!r} maps to "
|
||||
f"{semantic_subfolder!r}, model_subfolder="
|
||||
f"{explicit_subfolder!r}"
|
||||
)
|
||||
self.server_args.model_subfolder = semantic_subfolder
|
||||
model_index = super()._load_config()
|
||||
self.release_metadata = MiniMaxH3ReleaseMetadata.from_model_index(model_index)
|
||||
if (
|
||||
model_variant is not None
|
||||
and self.release_metadata.partition != model_variant.strip().lower()
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 loaded checkpoint partition does not match "
|
||||
f"--model-variant {model_variant!r}"
|
||||
)
|
||||
return model_index
|
||||
|
||||
def validate_disagg_role(self, role: RoleType) -> None:
|
||||
if role != RoleType.MONOLITHIC:
|
||||
raise ValueError(
|
||||
"MiniMaxH3Pipeline only supports monolithic deployment; "
|
||||
f"disaggregation role {role.value!r} is not supported"
|
||||
)
|
||||
|
||||
def create_pipeline_stages(self, server_args: ServerArgs) -> None:
|
||||
# Per-model sigma override from model_index.json; contract tests
|
||||
# construct the pipeline without model_path, hence the guard.
|
||||
release_metadata = getattr(self, "release_metadata", None)
|
||||
sigma_shift_scales = (
|
||||
release_metadata.sigma_shift_scales
|
||||
if release_metadata is not None
|
||||
else None
|
||||
)
|
||||
self.add_stage(InputValidationStage())
|
||||
if release_metadata is not None:
|
||||
self.add_stage(MiniMaxH3PartitionAdmissionStage(release_metadata))
|
||||
self.add_stage(
|
||||
MiniMaxH3TextEncodingStage(
|
||||
text_encoder=self.get_module("text_encoder"),
|
||||
tokenizer=self.get_module("tokenizer"),
|
||||
processor=self.get_module("processor"),
|
||||
)
|
||||
)
|
||||
self.add_stage(
|
||||
MiniMaxH3VisualEncodingStage(
|
||||
video_vae=self.get_module("video_vae"),
|
||||
vae_arch_config=server_args.pipeline_config.vae_config.arch_config,
|
||||
)
|
||||
)
|
||||
self.add_stage(
|
||||
MiniMaxH3AudioEncodingStage(
|
||||
audio_vae=self.get_module("audio_vae"),
|
||||
vae_arch_config=server_args.pipeline_config.audio_vae_config.arch_config,
|
||||
)
|
||||
)
|
||||
self.add_stage(MiniMaxH3LatentPreparationStage())
|
||||
self.add_stage(
|
||||
MiniMaxH3TimestepPreparationStage(
|
||||
sigma_shift_scales=sigma_shift_scales,
|
||||
)
|
||||
)
|
||||
self.add_stage(
|
||||
MiniMaxH3DenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
pipeline=self,
|
||||
)
|
||||
)
|
||||
self.add_stage(
|
||||
MiniMaxH3DecodingStage(
|
||||
video_vae=self.get_module("video_vae"),
|
||||
audio_vae=self.get_module("audio_vae"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
EntryClass = MiniMaxH3Pipeline
|
||||
@@ -82,6 +82,7 @@ class ComposedPipelineBase(ABC):
|
||||
|
||||
# the name of the pipeline it associated with, in diffusers
|
||||
pipeline_name: str
|
||||
default_model_subfolder: str | None = None
|
||||
|
||||
def is_lora_effective(self):
|
||||
return False
|
||||
@@ -172,7 +173,32 @@ class ComposedPipelineBase(ABC):
|
||||
self.modules[module_name] = module
|
||||
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
model_path = maybe_download_model(self.model_path, force_diffusers_model=True)
|
||||
model_subfolder = self.server_args.model_subfolder
|
||||
if model_subfolder is None and not os.path.isfile(
|
||||
os.path.join(self.model_path, "model_index.json")
|
||||
):
|
||||
model_subfolder = self.default_model_subfolder
|
||||
|
||||
if model_subfolder is None:
|
||||
model_path = maybe_download_model(
|
||||
self.model_path, force_diffusers_model=True
|
||||
)
|
||||
else:
|
||||
model_subfolder = os.path.normpath(model_subfolder)
|
||||
if (
|
||||
os.path.isabs(model_subfolder)
|
||||
or model_subfolder == ".."
|
||||
or model_subfolder.startswith(f"..{os.sep}")
|
||||
):
|
||||
raise ValueError(
|
||||
f"model_subfolder must stay inside the model repository: {model_subfolder!r}"
|
||||
)
|
||||
model_root = maybe_download_model(
|
||||
self.model_path,
|
||||
allow_patterns=[f"{model_subfolder}/**"],
|
||||
)
|
||||
model_path = os.path.join(model_root, model_subfolder)
|
||||
|
||||
self.model_path = model_path
|
||||
logger.info("Model path: %s", model_path)
|
||||
config = verify_model_config_and_directory(model_path)
|
||||
@@ -444,13 +470,26 @@ class ComposedPipelineBase(ABC):
|
||||
component_load_specs: list[ComponentLoadSpec] = []
|
||||
|
||||
# enqueue only real weight loads (e.g., scheduler, tokenizer is excluded); skipped/provided modules keep old handling
|
||||
for index, (
|
||||
module_name,
|
||||
(
|
||||
transformers_or_diffusers,
|
||||
architecture,
|
||||
),
|
||||
) in enumerate(model_index.items()):
|
||||
for index, (module_name, component_spec) in enumerate(model_index.items()):
|
||||
# Diffusers uses JSON null for unavailable optional components.
|
||||
# Check before unpacking the normal [library, architecture] pair.
|
||||
if component_spec is None:
|
||||
logger.warning(
|
||||
"Module %s in model_index.json has null value, removing from required_config_modules",
|
||||
module_name,
|
||||
)
|
||||
if module_name in self.required_config_modules:
|
||||
self.required_config_modules.remove(module_name)
|
||||
continue
|
||||
if (
|
||||
not isinstance(component_spec, (list, tuple))
|
||||
or len(component_spec) != 2
|
||||
):
|
||||
raise ValueError(
|
||||
f"Module {module_name!r} in model_index.json must be null or "
|
||||
f"a [library, architecture] pair, got {component_spec!r}"
|
||||
)
|
||||
transformers_or_diffusers, architecture = component_spec
|
||||
if transformers_or_diffusers is None:
|
||||
logger.warning(
|
||||
"Module %s in model_index.json has null value, removing from required_config_modules",
|
||||
|
||||
@@ -161,13 +161,23 @@ class DecodingStage(PipelineStage):
|
||||
def scale_and_shift(self, latents: torch.Tensor, server_args):
|
||||
return scale_and_shift_latents(latents, server_args, self.vae)
|
||||
|
||||
def _get_vae_decode_fn(self, vae, server_args: ServerArgs):
|
||||
def _get_vae_decode_fn(
|
||||
self,
|
||||
vae,
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
decode_fn=None,
|
||||
compiled_callable: ActiveTargetCompiledCallable | None = None,
|
||||
):
|
||||
decode_fn = decode_fn or vae.decode
|
||||
if not server_args.enable_torch_compile or not isinstance(vae, nn.Module):
|
||||
return vae.decode
|
||||
return decode_fn
|
||||
|
||||
compiled_callable = compiled_callable or self._compiled_vae_decode
|
||||
|
||||
will_compile = (
|
||||
self._compiled_vae_decode.target_id != id(vae)
|
||||
or self._compiled_vae_decode.compiled_module is None
|
||||
compiled_callable.target_id != id(vae)
|
||||
or compiled_callable.compiled_module is None
|
||||
)
|
||||
if current_platform.is_npu():
|
||||
compile_kwargs = build_torch_compile_kwargs(mode=None)
|
||||
@@ -183,8 +193,8 @@ class DecodingStage(PipelineStage):
|
||||
if will_compile:
|
||||
logger.info("Compiling VAE decode with mode: %s", mode)
|
||||
|
||||
return self._compiled_vae_decode.get_or_compile(
|
||||
vae, vae.decode, compile_kwargs=compile_kwargs
|
||||
return compiled_callable.get_or_compile(
|
||||
vae, decode_fn, compile_kwargs=compile_kwargs
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
|
||||
@@ -30,6 +30,7 @@ class StageDedupMixin:
|
||||
deduplicated_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_tensor_tree_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_deepcopy_output_fields: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_extra_output_keys: ClassVar[tuple[str, ...]] = ()
|
||||
deduplicated_extra_tensor_tree_output_keys: ClassVar[tuple[str, ...]] = ()
|
||||
|
||||
def run_grouped_requests(
|
||||
@@ -60,6 +61,7 @@ class StageDedupMixin:
|
||||
cls.deduplicated_output_fields
|
||||
or cls.deduplicated_tensor_tree_output_fields
|
||||
or cls.deduplicated_deepcopy_output_fields
|
||||
or cls.deduplicated_extra_output_keys
|
||||
or cls.deduplicated_extra_tensor_tree_output_keys
|
||||
)
|
||||
|
||||
@@ -109,8 +111,8 @@ class StageDedupMixin:
|
||||
tensor references, which is the low-overhead path for read-only outputs
|
||||
such as embeddings. Tensor-tree fields recursively clone tensors.
|
||||
Deepcopy fields are for mutable request-local runtime objects, such as
|
||||
scheduler instances. Extra keys clone selected ``Req.extra`` entries
|
||||
without replacing the destination extra dict.
|
||||
scheduler instances. Extra output keys follow the same shallow-copy
|
||||
contract, while extra tensor-tree keys recursively clone tensors.
|
||||
"""
|
||||
for field in self.deduplicated_output_fields:
|
||||
setattr(dst, field, self.copy_stage_output(getattr(src, field)))
|
||||
@@ -118,6 +120,9 @@ class StageDedupMixin:
|
||||
setattr(dst, field, self.clone_tensor_tree(getattr(src, field)))
|
||||
for field in self.deduplicated_deepcopy_output_fields:
|
||||
setattr(dst, field, deepcopy(getattr(src, field)))
|
||||
for key in self.deduplicated_extra_output_keys:
|
||||
if key in src.extra:
|
||||
dst.extra[key] = self.copy_stage_output(src.extra[key])
|
||||
for key in self.deduplicated_extra_tensor_tree_output_keys:
|
||||
if key in src.extra:
|
||||
dst.extra[key] = self.clone_tensor_tree(src.extra[key])
|
||||
|
||||
@@ -76,6 +76,9 @@ from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_c
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
|
||||
ComponentUse,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_resident_strategies import (
|
||||
is_fsdp_managed_module,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.layerwise_offload import (
|
||||
LayerwiseOffloadableModuleMixin,
|
||||
is_layerwise_offloaded_module,
|
||||
@@ -227,7 +230,11 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
num_attention_heads = (
|
||||
self.server_args.pipeline_config.dit_config.num_attention_heads
|
||||
)
|
||||
attn_head_size = hidden_size // num_attention_heads
|
||||
attn_head_size = getattr(
|
||||
self.server_args.pipeline_config.dit_config,
|
||||
"attention_head_dim",
|
||||
hidden_size // num_attention_heads,
|
||||
)
|
||||
|
||||
# torch compile
|
||||
# list of offloaded dit modules if torch compile is enabled. cleared after compile and warmup
|
||||
@@ -341,10 +348,9 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
not args.enable_torch_compile
|
||||
or not args.offload_during_compile
|
||||
or not args.warmup
|
||||
# a subclass with its own forward would never run the restore
|
||||
or type(self).forward is not DenoisingStage.forward
|
||||
or not self._owns_compile_warmup_lifecycle()
|
||||
or args.use_fsdp_inference
|
||||
or envs.SGLANG_CACHE_DIT_ENABLED
|
||||
or self._cache_dit_requested()
|
||||
or not isinstance(module, LayerwiseOffloadableModuleMixin)
|
||||
or is_layerwise_offloaded_module(module)
|
||||
):
|
||||
@@ -353,6 +359,15 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
if is_layerwise_offloaded_module(module):
|
||||
self._offloaded_dit_modules_for_compile.append(module)
|
||||
|
||||
def _owns_compile_warmup_lifecycle(self) -> bool:
|
||||
"""Whether ``forward`` enters ``_offload_for_torch_compile_warmup``.
|
||||
|
||||
Custom denoising loops opt in explicitly after wiring the same restore
|
||||
lifecycle. This keeps the safety guard without silently disabling the
|
||||
optimization solely because a model overrides ``forward``.
|
||||
"""
|
||||
return type(self).forward is DenoisingStage.forward
|
||||
|
||||
def _move_resident_components_for_warmup(self) -> list[torch.nn.Module]:
|
||||
"""Move resident non-DiT components off-device while the warmup
|
||||
denoising (the compile/autotune peak) runs; forward() moves them back."""
|
||||
@@ -365,6 +380,7 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
if (
|
||||
isinstance(module, torch.nn.Module)
|
||||
and id(module) not in dit_ids
|
||||
and not is_fsdp_managed_module(module)
|
||||
and not is_layerwise_offloaded_module(module)
|
||||
):
|
||||
param = next(module.parameters(), None)
|
||||
@@ -386,7 +402,7 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
self.server_args, "enable_torch_compile", False
|
||||
) or not isinstance(module, nn.Module):
|
||||
return
|
||||
if envs.SGLANG_CACHE_DIT_ENABLED and not self._cache_dit_enabled:
|
||||
if self._cache_dit_requested() and not self._cache_dit_enabled:
|
||||
logger.debug("Deferring torch.compile until cache-dit is enabled")
|
||||
return
|
||||
if self._torch_compile_registry.is_compiled(module):
|
||||
@@ -434,6 +450,9 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
def _cache_dit_dual_model_name(self) -> str:
|
||||
return "wan2.2"
|
||||
|
||||
def _cache_dit_requested(self) -> bool:
|
||||
return envs.SGLANG_CACHE_DIT_ENABLED
|
||||
|
||||
def _cache_dit_secondary_uses_primary_config(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -604,7 +623,7 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
|
||||
# Keep cache-dit disabled for ordinary warmup, but allow torch.compile
|
||||
# warmup to mount cache-dit before Dynamo traces the transformer.
|
||||
if not envs.SGLANG_CACHE_DIT_ENABLED:
|
||||
if not self._cache_dit_requested():
|
||||
return
|
||||
if batch.is_warmup and not getattr(
|
||||
self.server_args, "enable_torch_compile", False
|
||||
@@ -692,9 +711,9 @@ class DenoisingStage(PipelineStage, RolloutDenoisingMixin):
|
||||
logger.info(
|
||||
"cache-dit enabled on transformer (steps=%d, Fn=%d, Bn=%d, rdt=%.3f)",
|
||||
primary_num_steps,
|
||||
envs.SGLANG_CACHE_DIT_FN,
|
||||
envs.SGLANG_CACHE_DIT_BN,
|
||||
envs.SGLANG_CACHE_DIT_RDT,
|
||||
primary_config.Fn_compute_blocks,
|
||||
primary_config.Bn_compute_blocks,
|
||||
primary_config.residual_diff_threshold,
|
||||
)
|
||||
|
||||
self._cache_dit_enabled = True
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""MiniMax H3-specific pipeline stages."""
|
||||
|
||||
from .stages.audio_encoding import MiniMaxH3AudioEncodingStage
|
||||
from .stages.decoding import MiniMaxH3DecodingStage
|
||||
from .stages.denoising import MiniMaxH3DenoisingStage
|
||||
from .stages.latent_preparation import MiniMaxH3LatentPreparationStage
|
||||
from .stages.text_encoding import MiniMaxH3TextEncodingStage
|
||||
from .stages.timestep_preparation import MiniMaxH3TimestepPreparationStage
|
||||
from .stages.visual_encoding import MiniMaxH3VisualEncodingStage
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3AudioEncodingStage",
|
||||
"MiniMaxH3DecodingStage",
|
||||
"MiniMaxH3DenoisingStage",
|
||||
"MiniMaxH3LatentPreparationStage",
|
||||
"MiniMaxH3TextEncodingStage",
|
||||
"MiniMaxH3TimestepPreparationStage",
|
||||
"MiniMaxH3VisualEncodingStage",
|
||||
]
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 keyframe target-canvas preparation.
|
||||
|
||||
Geometry behavior:
|
||||
- auto-aspect canvases delegate to the shared adaptive v2 shape resolver;
|
||||
- cover-crop: aspect-preserving max-scale LANCZOS resize + center crop,
|
||||
upscaling refused unless explicitly allowed.
|
||||
|
||||
Both the Qwen presentation (pixel_values) and the visual-condition tokenizer consume
|
||||
the SAME prepared canvas image, so preparation
|
||||
is cached per request in batch.extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
)
|
||||
|
||||
MINIMAX_H3_CANVAS_MULTIPLE = 32
|
||||
MINIMAX_H3_PREPARED_KEYFRAMES_EXTRA_KEY = "minimax_h3_prepared_keyframes"
|
||||
|
||||
|
||||
def minimax_h3_cover_crop_plan(
|
||||
*,
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
allow_upscale: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministic aspect-preserving cover-crop transform."""
|
||||
if source_width <= 0 or source_height <= 0:
|
||||
raise ValueError("cover_crop requires positive source width/height")
|
||||
scale = max(
|
||||
target_width / float(source_width), target_height / float(source_height)
|
||||
)
|
||||
if scale > 1.0 and not allow_upscale:
|
||||
raise ValueError(
|
||||
"target_canvas cover_crop would upscale the source; set "
|
||||
f"allow_upscale=true (source={source_width}x{source_height}, "
|
||||
f"target={target_width}x{target_height})"
|
||||
)
|
||||
resized_width = max(target_width, int(round(source_width * scale)))
|
||||
resized_height = max(target_height, int(round(source_height * scale)))
|
||||
left = max(0, (resized_width - target_width) // 2)
|
||||
top = max(0, (resized_height - target_height) // 2)
|
||||
return {
|
||||
"scale": scale,
|
||||
"resized_size": (resized_width, resized_height),
|
||||
"crop_box": (left, top, left + target_width, top + target_height),
|
||||
}
|
||||
|
||||
|
||||
def minimax_h3_prepare_keyframe_canvas(
|
||||
image: Any,
|
||||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
allow_upscale: bool = False,
|
||||
) -> Any:
|
||||
"""Prepare a PIL image onto the target canvas.
|
||||
|
||||
Identity (no resample) when the image already IS the canvas.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
image = image.convert("RGB")
|
||||
if image.size == (target_width, target_height):
|
||||
return image
|
||||
plan = minimax_h3_cover_crop_plan(
|
||||
source_width=image.size[0],
|
||||
source_height=image.size[1],
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
allow_upscale=allow_upscale,
|
||||
)
|
||||
resized = image.resize(plan["resized_size"], Image.Resampling.LANCZOS)
|
||||
return resized.crop(plan["crop_box"])
|
||||
|
||||
|
||||
def minimax_h3_stretch_keyframe_canvas(
|
||||
image: Any,
|
||||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
) -> Any:
|
||||
"""Stretch the FL first frame directly onto the resolved target canvas."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
image = image.convert("RGB")
|
||||
if image.size == (target_width, target_height):
|
||||
return image
|
||||
return image.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def _keyframe_materials(plan: Any) -> list[Any]:
|
||||
return [m for m in plan.materials if m.material_chain == "image.target_canvas"]
|
||||
|
||||
|
||||
def _keyframe_canvas_size(shape: Any) -> tuple[int, int]:
|
||||
geometry = str(shape["geometry"])
|
||||
if geometry != "resolved_v2":
|
||||
raise ValueError(
|
||||
"fl2va keyframe preparation requires pre-queue resolved_v2 "
|
||||
f"geometry, got {geometry!r}"
|
||||
)
|
||||
return int(shape["width"]), int(shape["height"])
|
||||
|
||||
|
||||
def _validate_keyframe_materials(plan: Any, keyframes: list[Any]) -> tuple[int, ...]:
|
||||
if str(plan.task) != "fl2va":
|
||||
raise ValueError("keyframe target-canvas materials require plan.task='fl2va'")
|
||||
semantic_indices = tuple(material.frame_index for material in keyframes)
|
||||
if semantic_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"fl2va keyframes must use one of the ordered frame_index signatures "
|
||||
f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got {semantic_indices!r}"
|
||||
)
|
||||
frame_count = plan.shape.get("frame_count")
|
||||
if isinstance(frame_count, bool) or not isinstance(frame_count, int):
|
||||
raise ValueError("fl2va keyframe preparation requires an integer frame_count")
|
||||
if frame_count <= 1:
|
||||
raise ValueError("fl2va keyframe preparation requires frame_count > 1")
|
||||
expected_pixels = tuple(
|
||||
frame_count - 1 if index == -1 else index for index in semantic_indices
|
||||
)
|
||||
resolved_pixels = tuple(material.resolved_frame_index for material in keyframes)
|
||||
if resolved_pixels != expected_pixels:
|
||||
raise ValueError(
|
||||
"fl2va keyframe resolved_frame_index values disagree with semantic "
|
||||
f"anchors: expected {expected_pixels!r}, got {resolved_pixels!r}"
|
||||
)
|
||||
return semantic_indices
|
||||
|
||||
|
||||
def minimax_h3_prepared_keyframes(batch: Any, plan: Any) -> dict[str, Any]:
|
||||
"""Resolve + prepare one or two fl2va keyframes once per request.
|
||||
|
||||
The target canvas is shared across keyframes and must already be frozen by
|
||||
the pre-queue probe/resolve hook.
|
||||
Top-level ``image`` / ``canvas_width`` / ``canvas_height`` keys mirror the
|
||||
first-keyframe payload for compatibility; per-keyframe entries live under
|
||||
``images``.
|
||||
"""
|
||||
keyframes = _keyframe_materials(plan)
|
||||
semantic_indices = _validate_keyframe_materials(plan, keyframes)
|
||||
cached = batch.extra.get(MINIMAX_H3_PREPARED_KEYFRAMES_EXTRA_KEY)
|
||||
if cached is not None:
|
||||
cached_indices = tuple(cached.get("semantic_frame_indices") or ())
|
||||
cached_images = cached.get("images") or ()
|
||||
if cached_indices != semantic_indices or len(cached_images) != len(keyframes):
|
||||
raise ValueError(
|
||||
"cached fl2va keyframe preparation disagrees with the resolved plan"
|
||||
)
|
||||
return cached
|
||||
|
||||
canvas_w, canvas_h = _keyframe_canvas_size(plan.shape)
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.prequeue import (
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY,
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY,
|
||||
)
|
||||
|
||||
probe_facts = batch.extra.get(MINIMAX_H3_PROBE_FACTS_EXTRA_KEY)
|
||||
material_shapes = batch.extra.get(MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY)
|
||||
for material in keyframes:
|
||||
condition_index = int(material.condition_index)
|
||||
facts = (
|
||||
probe_facts.get(condition_index) if isinstance(probe_facts, dict) else None
|
||||
)
|
||||
material_shape = (
|
||||
material_shapes.get(condition_index)
|
||||
if isinstance(material_shapes, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(facts, dict) or not isinstance(material_shape, dict):
|
||||
raise ValueError(
|
||||
"fl2va keyframe preparation requires cached pre-queue probe and "
|
||||
f"shape facts for conditions[{condition_index}]"
|
||||
)
|
||||
if (
|
||||
int(material_shape.get("width") or 0),
|
||||
int(material_shape.get("height") or 0),
|
||||
) != (canvas_w, canvas_h):
|
||||
raise ValueError(
|
||||
"fl2va keyframe material shape disagrees with the resolved target: "
|
||||
f"condition={condition_index}, material="
|
||||
f"{material_shape.get('width')}x{material_shape.get('height')}, "
|
||||
f"target={canvas_w}x{canvas_h}"
|
||||
)
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_localize_material_uri,
|
||||
)
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for keyframe_index, material in enumerate(keyframes):
|
||||
image_path = minimax_h3_localize_material_uri(
|
||||
batch,
|
||||
material.uri,
|
||||
condition_type=material.condition_type,
|
||||
condition_index=int(material.condition_index),
|
||||
)
|
||||
with Image.open(image_path) as source_image:
|
||||
image = ImageOps.exif_transpose(source_image)
|
||||
# A request's first semantic keyframe is its geometry anchor, including
|
||||
# the single-image last-frame-only signature [-1]. Only the second image in the
|
||||
# two-keyframe FL extension is a follower and receives cover-crop.
|
||||
prepared_image = (
|
||||
minimax_h3_stretch_keyframe_canvas(
|
||||
image,
|
||||
target_width=canvas_w,
|
||||
target_height=canvas_h,
|
||||
)
|
||||
if keyframe_index == 0
|
||||
else minimax_h3_prepare_keyframe_canvas(
|
||||
image,
|
||||
target_width=canvas_w,
|
||||
target_height=canvas_h,
|
||||
allow_upscale=True,
|
||||
)
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"image": prepared_image,
|
||||
"canvas_width": canvas_w,
|
||||
"canvas_height": canvas_h,
|
||||
"condition_index": int(material.condition_index),
|
||||
"frame_index": (
|
||||
None if material.frame_index is None else int(material.frame_index)
|
||||
),
|
||||
"resolved_frame_index": (
|
||||
None
|
||||
if material.resolved_frame_index is None
|
||||
else int(material.resolved_frame_index)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"image": entries[0]["image"],
|
||||
"canvas_width": canvas_w,
|
||||
"canvas_height": canvas_h,
|
||||
"images": entries,
|
||||
"semantic_frame_indices": [
|
||||
int(item["frame_index"])
|
||||
for item in entries
|
||||
if item.get("frame_index") is not None
|
||||
],
|
||||
"pixel_frame_indices": [
|
||||
int(item["resolved_frame_index"])
|
||||
for item in entries
|
||||
if item.get("resolved_frame_index") is not None
|
||||
],
|
||||
"frame_count": (
|
||||
int(plan.shape["frame_count"])
|
||||
if plan.shape.get("frame_count") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
batch.extra[MINIMAX_H3_PREPARED_KEYFRAMES_EXTRA_KEY] = payload
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_CANVAS_MULTIPLE",
|
||||
"MINIMAX_H3_PREPARED_KEYFRAMES_EXTRA_KEY",
|
||||
"minimax_h3_cover_crop_plan",
|
||||
"minimax_h3_prepare_keyframe_canvas",
|
||||
"minimax_h3_prepared_keyframes",
|
||||
"minimax_h3_stretch_keyframe_canvas",
|
||||
]
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 visual/audio condition-noise augmentation.
|
||||
|
||||
The request's condition timestep is applied to both the tensor value and the
|
||||
DiT timestep. Tokenizer artifacts remain clean
|
||||
and reusable; this module materializes the fixed noised anchors immediately
|
||||
before the denoise loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_tokens import (
|
||||
minimax_h3_patchify_video_latent,
|
||||
)
|
||||
|
||||
# Channel-major packed audio rows always carry a stereo layout.
|
||||
MINIMAX_H3_AUDIO_COND_CHANNELS = 2
|
||||
|
||||
|
||||
def minimax_h3_imgvid_cond_noise_aug_rows(
|
||||
clean_rows: torch.Tensor,
|
||||
*,
|
||||
condition_shapes: Sequence[Sequence[int]],
|
||||
target_latent_t: int,
|
||||
imgvid_cond_num_frames: int,
|
||||
seed: int,
|
||||
noise_aug: float,
|
||||
) -> torch.Tensor:
|
||||
"""Apply the imgvid-condition RF noise recipe to packed clean rows.
|
||||
|
||||
``condition_shapes`` contains ``(latent_t, latent_h, latent_w)`` in packed
|
||||
visual-condition order. A new CPU generator with the same row seed is
|
||||
created for every condition. Under the dependent-noise policy, each draw
|
||||
uses the target temporal length plus the template's imgvid-condition frame
|
||||
count, then slices the prefix matching the current condition.
|
||||
"""
|
||||
|
||||
noise_aug = float(noise_aug)
|
||||
if not 0.0 <= noise_aug <= 1.0:
|
||||
raise ValueError(f"noise_aug must be in [0, 1], got {noise_aug}")
|
||||
if noise_aug == 1.0:
|
||||
return clean_rows
|
||||
if clean_rows.ndim != 2 or int(clean_rows.shape[1]) != 96:
|
||||
raise ValueError(
|
||||
"clean imgvid condition rows must have shape [n, 96], got "
|
||||
f"{list(clean_rows.shape)}"
|
||||
)
|
||||
|
||||
target_latent_t = int(target_latent_t)
|
||||
imgvid_cond_num_frames = int(imgvid_cond_num_frames)
|
||||
if target_latent_t <= 0:
|
||||
raise ValueError(f"target_latent_t must be positive, got {target_latent_t}")
|
||||
if imgvid_cond_num_frames <= 0:
|
||||
raise ValueError(
|
||||
"imgvid_cond_num_frames must be positive when condition rows exist, "
|
||||
f"got {imgvid_cond_num_frames}"
|
||||
)
|
||||
|
||||
parsed_shapes: list[tuple[int, int, int]] = []
|
||||
expected_rows = 0
|
||||
for raw_shape in condition_shapes:
|
||||
if len(raw_shape) != 3:
|
||||
raise ValueError(
|
||||
"each imgvid condition shape must be (latent_t, latent_h, latent_w), "
|
||||
f"got {list(raw_shape)}"
|
||||
)
|
||||
latent_t, latent_h, latent_w = (int(value) for value in raw_shape)
|
||||
if latent_t <= 0 or latent_h <= 0 or latent_w <= 0:
|
||||
raise ValueError(
|
||||
f"imgvid condition shape must be positive, got {list(raw_shape)}"
|
||||
)
|
||||
if latent_h % 2 or latent_w % 2:
|
||||
raise ValueError(
|
||||
"imgvid condition spatial dimensions must be divisible by 2, "
|
||||
f"got {(latent_t, latent_h, latent_w)}"
|
||||
)
|
||||
parsed_shapes.append((latent_t, latent_h, latent_w))
|
||||
expected_rows += latent_t * (latent_h // 2) * (latent_w // 2)
|
||||
if not parsed_shapes:
|
||||
raise ValueError("condition_shapes must not be empty")
|
||||
if int(clean_rows.shape[0]) != expected_rows:
|
||||
raise ValueError(
|
||||
f"clean imgvid condition rows {int(clean_rows.shape[0])} != "
|
||||
f"shape-derived rows {expected_rows}"
|
||||
)
|
||||
|
||||
out: list[torch.Tensor] = []
|
||||
row_offset = 0
|
||||
timestep = torch.tensor(noise_aug, dtype=torch.float32, device=clean_rows.device)
|
||||
for latent_t, latent_h, latent_w in parsed_shapes:
|
||||
full_t = target_latent_t + imgvid_cond_num_frames
|
||||
if full_t < latent_t:
|
||||
raise ValueError(
|
||||
f"condition latent_t {latent_t} exceeds the noise draw "
|
||||
f"length {full_t}"
|
||||
)
|
||||
generator = torch.Generator(device="cpu").manual_seed(int(seed))
|
||||
noise = torch.randn(
|
||||
1,
|
||||
24,
|
||||
full_t,
|
||||
latent_h,
|
||||
latent_w,
|
||||
generator=generator,
|
||||
dtype=torch.float32,
|
||||
device="cpu",
|
||||
)[:, :, :latent_t]
|
||||
noise_rows = minimax_h3_patchify_video_latent(noise, patch_size=[1, 2, 2]).to(
|
||||
device=clean_rows.device, dtype=torch.float32
|
||||
)
|
||||
row_count = int(noise_rows.shape[0])
|
||||
clean_part = clean_rows[row_offset : row_offset + row_count].to(torch.float32)
|
||||
out.append(timestep * clean_part + (1.0 - timestep) * noise_rows)
|
||||
row_offset += row_count
|
||||
return (out[0] if len(out) == 1 else torch.cat(out, dim=0)).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_audio_cond_noise_aug_rows(
|
||||
clean_rows: torch.Tensor,
|
||||
*,
|
||||
condition_audio_t: Sequence[int],
|
||||
seed: int,
|
||||
noise_aug: float,
|
||||
) -> torch.Tensor:
|
||||
"""Apply the audio-condition RF noise recipe to packed clean rows.
|
||||
|
||||
``condition_audio_t`` contains the latent T of each audio-bearing
|
||||
condition in canonical request order. Noise is drawn per condition
|
||||
element, with a fresh CPU generator seeded with ``seed + 1`` for every
|
||||
element. Consequently each condition restarts the
|
||||
same RNG stream; concatenating the rows and drawing once would be
|
||||
numerically different for ordered multi-reference requests.
|
||||
|
||||
The mix is intentionally evaluated on CPU in fp32 before the packed rows
|
||||
are transferred to the DiT device.
|
||||
"""
|
||||
|
||||
noise_aug = float(noise_aug)
|
||||
if not 0.0 <= noise_aug <= 1.0:
|
||||
raise ValueError(f"noise_aug must be in [0, 1], got {noise_aug}")
|
||||
if noise_aug == 1.0:
|
||||
return clean_rows
|
||||
if clean_rows.ndim != 2 or int(clean_rows.shape[1]) != 32:
|
||||
raise ValueError(
|
||||
"clean audio condition rows must have shape [n, 32], got "
|
||||
f"{list(clean_rows.shape)}"
|
||||
)
|
||||
|
||||
audio_channels = MINIMAX_H3_AUDIO_COND_CHANNELS
|
||||
parsed_audio_t = [int(value) for value in condition_audio_t]
|
||||
if not parsed_audio_t:
|
||||
raise ValueError("condition_audio_t must not be empty")
|
||||
if any(value <= 0 for value in parsed_audio_t):
|
||||
raise ValueError(
|
||||
f"condition audio latent lengths must be positive, got {parsed_audio_t}"
|
||||
)
|
||||
expected_rows = audio_channels * sum(parsed_audio_t)
|
||||
if int(clean_rows.shape[0]) != expected_rows:
|
||||
raise ValueError(
|
||||
f"clean audio condition rows {int(clean_rows.shape[0])} != "
|
||||
f"shape-derived rows {expected_rows}"
|
||||
)
|
||||
|
||||
out: list[torch.Tensor] = []
|
||||
row_offset = 0
|
||||
timestep = torch.tensor(noise_aug, dtype=torch.float32, device="cpu")
|
||||
for audio_t in parsed_audio_t:
|
||||
row_count = audio_channels * audio_t
|
||||
clean_part = (
|
||||
clean_rows[row_offset : row_offset + row_count]
|
||||
.detach()
|
||||
.to(device="cpu", dtype=torch.float32)
|
||||
)
|
||||
generator = torch.Generator(device="cpu").manual_seed(int(seed) + 1)
|
||||
noise = torch.randn(
|
||||
clean_part.shape,
|
||||
generator=generator,
|
||||
dtype=torch.float32,
|
||||
device="cpu",
|
||||
)
|
||||
out.append(timestep * clean_part + (1.0 - timestep) * noise)
|
||||
row_offset += row_count
|
||||
rows = out[0] if len(out) == 1 else torch.cat(out, dim=0)
|
||||
return rows.to(device=clean_rows.device, dtype=torch.float32).contiguous()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_audio_cond_noise_aug_rows",
|
||||
"minimax_h3_imgvid_cond_noise_aug_rows",
|
||||
]
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
# Direct-encode text embeddings: {"positive":
|
||||
# {"hidden_states": Tensor[text_len, 5120] bf16 cpu, "text_len": int}}
|
||||
MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY = "minimax_h3_text_embeddings"
|
||||
# Direct keyframe encode: {"rows": Tensor[n_rows, 96] fp32 cpu,
|
||||
# "latent_h": int, "latent_w": int, "canvas_height": int,
|
||||
# "canvas_width": int, "keyframes": [...],
|
||||
# "semantic_frame_indices": [...], "pixel_frame_indices": [...]}
|
||||
MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY = "minimax_h3_keyframe_cond_rows"
|
||||
# Direct sigma schedules: {"video": [float], "audio": [float]}
|
||||
MINIMAX_H3_SIGMAS_EXTRA_KEY = "minimax_h3_sigmas"
|
||||
# Direct denoise state: {"initial_video_rows", "initial_audio_rows",
|
||||
# "latent_t", "latent_h", "latent_w", "audio_t"}
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY = "minimax_h3_denoise_state"
|
||||
# ref2va direct reference encodes.
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY = "minimax_h3_reference_image_rows"
|
||||
MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY = "minimax_h3_reference_audio_rows"
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY = "minimax_h3_reference_video_rows"
|
||||
MINIMAX_H3_PREPARED_REFERENCE_VIDEO_EXTRA_KEY = "minimax_h3_prepared_reference_video"
|
||||
|
||||
MINIMAX_H3_SUPPORTED_FPS = 24
|
||||
MINIMAX_H3_MIN_DURATION_SECONDS = 4.0
|
||||
MINIMAX_H3_MAX_DURATION_SECONDS = 15.0
|
||||
|
||||
# The distilled checkpoint has exactly one positive denoise branch.
|
||||
MINIMAX_H3_DEFAULT_BRANCHES: tuple = ({"name": "cond_1"},)
|
||||
|
||||
# Audited 4xH200 T2VA profiles. The tuple is
|
||||
# (warmup steps, residual-difference threshold, max consecutive cached steps).
|
||||
MINIMAX_H3_QUALITY_PROFILES: dict[str, tuple[int, float, int] | None] = {
|
||||
"lossless": None,
|
||||
"high": (4, 0.04, 1),
|
||||
"medium": (4, 0.12, 3),
|
||||
"low": (4, 0.24, 3),
|
||||
}
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 cfg-distilled full denoise loop.
|
||||
|
||||
Per step, the positive presentation is forwarded exactly once. Video and audio
|
||||
target rows chain through the Euler-eta0 update while visual and audio condition
|
||||
rows stay pinned to their noised step-0 anchors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import Any, Callable
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import (
|
||||
MINIMAX_H3_ADALN_MODALITY_NUM,
|
||||
)
|
||||
|
||||
MINIMAX_H3_IMGVID_COND_TIMESTEP = 0.999
|
||||
# ref2va audio reference anchor timestep
|
||||
MINIMAX_H3_AUDIO_REF_COND_TIMESTEP = 1.0
|
||||
# Packed row widths: video rows are [1,2,2]-patchified 24-channel latents
|
||||
# (24 * 1 * 2 * 2 = 96); audio rows carry the 32-dim audio latent.
|
||||
MINIMAX_H3_VIDEO_ROW_WIDTH = 96
|
||||
MINIMAX_H3_AUDIO_ROW_WIDTH = 32
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def _minimax_h3_update_target_rows_(
|
||||
state: torch.Tensor,
|
||||
velocity: torch.Tensor,
|
||||
*,
|
||||
sigma_t: torch.Tensor,
|
||||
sigma_curr: float,
|
||||
sigma_ratio: torch.Tensor,
|
||||
one_minus_sigma_ratio: torch.Tensor,
|
||||
denoised_scratch: torch.Tensor,
|
||||
) -> None:
|
||||
torch.mul(sigma_t, velocity, out=denoised_scratch)
|
||||
torch.add(state, denoised_scratch, out=denoised_scratch)
|
||||
if sigma_curr == 0.0:
|
||||
return
|
||||
torch.mul(one_minus_sigma_ratio, denoised_scratch, out=velocity)
|
||||
torch.mul(sigma_ratio, state, out=state)
|
||||
torch.add(state, velocity, out=state)
|
||||
|
||||
|
||||
def _ulysses_ctx() -> tuple[int, int]:
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_ulysses_parallel_rank,
|
||||
get_ulysses_parallel_world_size,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
return get_ulysses_parallel_world_size(), get_ulysses_parallel_rank()
|
||||
|
||||
|
||||
def _build_local_embedding_layout(
|
||||
*,
|
||||
seq_len: int,
|
||||
text_pos: torch.Tensor,
|
||||
img_pos: torch.Tensor,
|
||||
audio_pos: torch.Tensor,
|
||||
world_size: int,
|
||||
rank: int,
|
||||
device: torch.device,
|
||||
) -> dict[str, torch.Tensor | int]:
|
||||
if seq_len % world_size:
|
||||
raise ValueError(
|
||||
f"packed seq_len {seq_len} not divisible by Ulysses world size "
|
||||
f"{world_size}"
|
||||
)
|
||||
local_seq_len = seq_len // world_size
|
||||
row_start = rank * local_seq_len
|
||||
row_stop = row_start + local_seq_len
|
||||
|
||||
def local_ids(pos: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
source_ids = torch.nonzero(
|
||||
(pos >= row_start) & (pos < row_stop),
|
||||
as_tuple=False,
|
||||
).view(-1)
|
||||
return source_ids.to(device), pos.index_select(0, source_ids).to(device)
|
||||
|
||||
text_source_start = min(row_start, int(text_pos.shape[0]))
|
||||
text_source_stop = min(row_stop, int(text_pos.shape[0]))
|
||||
_, img_global_ids = local_ids(img_pos)
|
||||
_, audio_global_ids = local_ids(audio_pos)
|
||||
return {
|
||||
"text_source_start": text_source_start,
|
||||
"text_source_stop": text_source_stop,
|
||||
"img_global_ids": img_global_ids,
|
||||
"img_row_ids": img_global_ids - row_start,
|
||||
"audio_global_ids": audio_global_ids,
|
||||
"audio_row_ids": audio_global_ids - row_start,
|
||||
}
|
||||
|
||||
|
||||
class MiniMaxH3DenoiseBranch:
|
||||
"""Static per-branch state: packed layout + fixed forward kwargs.
|
||||
|
||||
`packed` is a minimax_h3_packed_sequence(...) result (or equivalent layout
|
||||
dict); `text_embeddings` is the branch's [text_len, 5120] hidden states;
|
||||
`token_tags` must already carry any fl2va vision-span overrides.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
packed: dict[str, torch.Tensor],
|
||||
text_embeddings: torch.Tensor,
|
||||
token_tags: torch.Tensor,
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
seq_len = int(packed["seq_len"])
|
||||
self.seq_len = seq_len
|
||||
self.img_pos = packed["img_pos"].view(-1).to(torch.long)
|
||||
self.audio_pos = packed["audio_pos"].view(-1).to(torch.long)
|
||||
self.update_mask = packed["update_mask"].view(-1).to(torch.bool)
|
||||
# ref2va: audio_pos may include reference-audio anchor rows
|
||||
# (audio_update_mask False); absent means all rows are targets.
|
||||
if "audio_update_mask" in packed:
|
||||
self.audio_update_mask = packed["audio_update_mask"].view(-1).to(torch.bool)
|
||||
else:
|
||||
self.audio_update_mask = torch.ones(
|
||||
self.audio_pos.shape[0], dtype=torch.bool
|
||||
)
|
||||
# Packed H3 layouts place reference rows before the generated suffix
|
||||
# for both modalities. Keep the suffix boundary once so the denoise
|
||||
# hot path can update contiguous views instead of gathering and
|
||||
# scattering the same static index tensors on every step.
|
||||
self.video_target_start = int((~self.update_mask).sum())
|
||||
self.audio_target_start = int((~self.audio_update_mask).sum())
|
||||
self.video_target_slice = slice(self.video_target_start, None)
|
||||
self.audio_target_slice = slice(self.audio_target_start, None)
|
||||
text_pos = packed["text_pos"].view(-1).to(torch.long)
|
||||
text_len = int(text_pos.shape[0])
|
||||
if list(text_embeddings.shape)[0] != text_len:
|
||||
raise ValueError(
|
||||
f"text_embeddings rows {list(text_embeddings.shape)} != "
|
||||
f"packed text_len {text_len}"
|
||||
)
|
||||
if int(token_tags.view(-1).shape[0]) != seq_len:
|
||||
raise ValueError(
|
||||
f"token_tags length {int(token_tags.view(-1).shape[0])} != "
|
||||
f"seq_len {seq_len}"
|
||||
)
|
||||
cu = packed["cu_seqlens"].to(torch.int32)
|
||||
self.img_pos_dev = self.img_pos.to(device)
|
||||
self.audio_pos_dev = self.audio_pos.to(device)
|
||||
self.update_mask_dev = self.update_mask.to(device)
|
||||
self.audio_update_mask_dev = self.audio_update_mask.to(device)
|
||||
# Resolve the remaining step-static packed-sequence and anchor row
|
||||
# sets once, keeping nonzero-driven work out of the hot loop.
|
||||
self.img_cond_seq_idx = self.img_pos_dev[~self.update_mask_dev]
|
||||
self.img_target_seq_idx = self.img_pos_dev[self.update_mask_dev]
|
||||
self.audio_target_seq_idx = self.audio_pos_dev[self.audio_update_mask_dev]
|
||||
self.audio_ref_seq_idx = self.audio_pos_dev[~self.audio_update_mask_dev]
|
||||
self.cond_row_idx = torch.nonzero(~self.update_mask_dev).view(-1)
|
||||
self.audio_ref_row_idx = torch.nonzero(~self.audio_update_mask_dev).view(-1)
|
||||
# rows that keep the video timestep each step: text, padding, and
|
||||
# video target rows — everything the three overwrite sets do not cover
|
||||
self.n_video_timestep_rows = (
|
||||
seq_len
|
||||
- int(self.img_cond_seq_idx.numel())
|
||||
- int(self.audio_target_seq_idx.numel())
|
||||
- int(self.audio_ref_seq_idx.numel())
|
||||
)
|
||||
# persistent packed-row buffers; every img/audio position is fully
|
||||
# rewritten by index_copy_ on the first forward_kwargs() call, then
|
||||
# only the target-row subset each step after (condition/reference
|
||||
# rows never change post-priming -- see forward_kwargs).
|
||||
self._x_buffer_primed = False
|
||||
self.x_buffer = torch.zeros(
|
||||
1, seq_len, MINIMAX_H3_VIDEO_ROW_WIDTH, dtype=torch.float32, device=device
|
||||
)
|
||||
self.audio_x_buffer = torch.zeros(
|
||||
1, seq_len, MINIMAX_H3_AUDIO_ROW_WIDTH, dtype=torch.float32, device=device
|
||||
)
|
||||
text_pos_dev = text_pos.to(device)
|
||||
ulysses_world_size, ulysses_rank = _ulysses_ctx()
|
||||
token_tags_host = token_tags.view(-1).to(dtype=torch.long)
|
||||
local_seq_len = seq_len // ulysses_world_size
|
||||
local_row_start = ulysses_rank * local_seq_len
|
||||
local_row_stop = local_row_start + local_seq_len
|
||||
self.local_row_slice = slice(local_row_start, local_row_stop)
|
||||
self.block_token_tags = (
|
||||
token_tags_host[local_row_start:local_row_stop].clamp(min=0).to(device)
|
||||
)
|
||||
self.static_kwargs: dict[str, Any] = {
|
||||
# Cast the fp64 position grid on the host. MiniMaxH3Rope casts it to
|
||||
# fp32 as its first op anyway, so the values are identical; doing the
|
||||
# cast on CPU also avoids platforms that cannot execute fp64 on
|
||||
# device (e.g. Iluvatar CoreX returns zeros for device fp64).
|
||||
"img_position_ids": packed["img_position_ids"][None]
|
||||
.to(torch.float32)
|
||||
.to(device),
|
||||
"update_mask": self.update_mask_dev,
|
||||
"block_token_tags": self.block_token_tags,
|
||||
"skip_mask_out_condition": True,
|
||||
"prompt_embeds": text_embeddings.to(device),
|
||||
"img_pos_info": {"position_ids": self.img_pos_dev},
|
||||
"audio_pos_info": {"position_ids": self.audio_pos_dev},
|
||||
"text_pos_info": {"position_ids": text_pos_dev},
|
||||
"img_pos_for_infer_output_info": {"position_ids": self.img_target_seq_idx},
|
||||
"local_embedding_layout": _build_local_embedding_layout(
|
||||
seq_len=seq_len,
|
||||
text_pos=text_pos,
|
||||
img_pos=self.img_pos,
|
||||
audio_pos=self.audio_pos,
|
||||
world_size=ulysses_world_size,
|
||||
rank=ulysses_rank,
|
||||
device=device,
|
||||
),
|
||||
"packed_seq_params": {
|
||||
"cu_seqlens_q": cu.to(device),
|
||||
"cu_seqlens_q_host": tuple(int(value) for value in cu.tolist()),
|
||||
"max_seqlen_q": int(cu[1]),
|
||||
},
|
||||
"refiner_packed_seq_params": {
|
||||
"cu_seqlens_q": torch.tensor(
|
||||
[0, text_len, text_len], dtype=torch.int32, device=device
|
||||
),
|
||||
"cu_seqlens_q_host": (0, text_len, text_len),
|
||||
"max_seqlen_q": text_len,
|
||||
},
|
||||
}
|
||||
|
||||
def forward_kwargs(
|
||||
self,
|
||||
*,
|
||||
video_rows: torch.Tensor,
|
||||
audio_rows: torch.Tensor,
|
||||
step_timesteps: tuple[torch.Tensor, torch.Tensor, torch.Tensor],
|
||||
) -> dict[str, Any]:
|
||||
x = self.x_buffer
|
||||
audio_x = self.audio_x_buffer
|
||||
if not self._x_buffer_primed:
|
||||
# First step: condition/reference rows have just been pinned into
|
||||
# video_rows/audio_rows (see minimax_h3_denoise_loop) and never
|
||||
# change again, so this is the only step that needs the full
|
||||
# img/audio extent written into the persistent buffers.
|
||||
x[0].index_copy_(0, self.img_pos_dev, video_rows)
|
||||
audio_x[0].index_copy_(0, self.audio_pos_dev, audio_rows)
|
||||
self._x_buffer_primed = True
|
||||
else:
|
||||
# Later steps: only the target-row subset changed since the
|
||||
# buffers were primed; rewriting condition/reference rows again
|
||||
# would just copy the same bytes already sitting there.
|
||||
x[0].index_copy_(
|
||||
0, self.img_target_seq_idx, video_rows[self.video_target_slice]
|
||||
)
|
||||
audio_x[0].index_copy_(
|
||||
0, self.audio_target_seq_idx, audio_rows[self.audio_target_slice]
|
||||
)
|
||||
unique_timesteps, inverse_indices, block_combined_indices = step_timesteps
|
||||
return {
|
||||
**self.static_kwargs,
|
||||
"x": x,
|
||||
"audio_x": audio_x,
|
||||
"unique_timesteps": unique_timesteps,
|
||||
"inverse_indices": inverse_indices,
|
||||
"block_combined_indices": block_combined_indices,
|
||||
}
|
||||
|
||||
def _expand_step_timesteps(
|
||||
self,
|
||||
*,
|
||||
t_video: float,
|
||||
t_audio: float,
|
||||
imgvid_cond_timestep: float,
|
||||
audio_ref_cond_timestep: float,
|
||||
inverse_indices_by_pattern: dict[tuple[int, ...], torch.Tensor],
|
||||
block_combined_by_pattern: dict[tuple[int, ...], torch.Tensor],
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Build step-local timestep and AdaLN index tensors.
|
||||
|
||||
Packed-sequence timestep semantics: non-media rows (text and padding)
|
||||
inherit the current video timestep, condition rows pin their noise-aug
|
||||
timesteps. The row->timestep layout is step-static, so instead of
|
||||
materializing the full timestep tensor and paying a device-syncing
|
||||
torch.unique per step, the at-most-four candidate values are deduped
|
||||
in fp32 on the host — torch.unique on the candidate tensor keeps exact
|
||||
fp32 collision semantics — and inverse indices are index_fill'ed from
|
||||
the static position sets.
|
||||
"""
|
||||
candidates: list[float] = []
|
||||
fill_groups: list[tuple[torch.Tensor, int]] = []
|
||||
base_slot = -1
|
||||
if self.n_video_timestep_rows > 0:
|
||||
base_slot = len(candidates)
|
||||
candidates.append(float(t_video))
|
||||
for seq_idx, value in (
|
||||
(self.img_cond_seq_idx, imgvid_cond_timestep),
|
||||
(self.audio_target_seq_idx, t_audio),
|
||||
(self.audio_ref_seq_idx, audio_ref_cond_timestep),
|
||||
):
|
||||
if seq_idx.numel() > 0:
|
||||
fill_groups.append((seq_idx, len(candidates)))
|
||||
candidates.append(float(value))
|
||||
unique_cpu, slot_to_unique = torch.unique(
|
||||
torch.tensor(candidates, dtype=torch.float32),
|
||||
sorted=True,
|
||||
return_inverse=True,
|
||||
)
|
||||
device = self.img_pos_dev.device
|
||||
base_index = int(slot_to_unique[base_slot]) if base_slot >= 0 else 0
|
||||
pattern = tuple(slot_to_unique.tolist())
|
||||
inverse_indices = inverse_indices_by_pattern.get(pattern)
|
||||
if inverse_indices is None:
|
||||
inverse_indices = torch.full(
|
||||
(self.seq_len,), base_index, dtype=torch.long, device=device
|
||||
)
|
||||
for seq_idx, slot in fill_groups:
|
||||
inverse_indices.index_fill_(0, seq_idx, int(slot_to_unique[slot]))
|
||||
inverse_indices_by_pattern[pattern] = inverse_indices
|
||||
block_combined = block_combined_by_pattern.get(pattern)
|
||||
if block_combined is None:
|
||||
block_combined = torch.add(
|
||||
self.block_token_tags,
|
||||
inverse_indices[self.local_row_slice],
|
||||
alpha=MINIMAX_H3_ADALN_MODALITY_NUM,
|
||||
)
|
||||
block_combined_by_pattern[pattern] = block_combined
|
||||
return unique_cpu.to(device), inverse_indices, block_combined
|
||||
|
||||
def prepare_timestep_plan(
|
||||
self,
|
||||
*,
|
||||
video_timesteps: list[float],
|
||||
audio_timesteps: list[float],
|
||||
imgvid_cond_noise_aug: float,
|
||||
audio_ref_cond_noise_aug: float,
|
||||
) -> list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
|
||||
"""Stage every step's packed timestep state before denoising."""
|
||||
if len(video_timesteps) != len(audio_timesteps):
|
||||
raise ValueError("video/audio timestep plans must have equal length")
|
||||
inverse_indices_by_pattern: dict[tuple[int, ...], torch.Tensor] = {}
|
||||
block_combined_by_pattern: dict[tuple[int, ...], torch.Tensor] = {}
|
||||
return [
|
||||
self._expand_step_timesteps(
|
||||
t_video=t_video,
|
||||
t_audio=t_audio,
|
||||
imgvid_cond_timestep=max(t_video, imgvid_cond_noise_aug),
|
||||
audio_ref_cond_timestep=max(t_audio, audio_ref_cond_noise_aug),
|
||||
inverse_indices_by_pattern=inverse_indices_by_pattern,
|
||||
block_combined_by_pattern=block_combined_by_pattern,
|
||||
)
|
||||
for t_video, t_audio in zip(video_timesteps, audio_timesteps)
|
||||
]
|
||||
|
||||
|
||||
def minimax_h3_denoise_loop(
|
||||
*,
|
||||
model: Any,
|
||||
model_forward: (
|
||||
Callable[[Any, dict[str, Any], int], tuple[torch.Tensor, torch.Tensor]] | None
|
||||
) = None,
|
||||
positive: MiniMaxH3DenoiseBranch,
|
||||
initial_video_rows: torch.Tensor,
|
||||
initial_audio_rows: torch.Tensor,
|
||||
keyframe_cond_rows: torch.Tensor | None,
|
||||
audio_ref_rows: torch.Tensor | None = None,
|
||||
sigmas_video: list[float],
|
||||
sigmas_audio: list[float],
|
||||
device: torch.device,
|
||||
imgvid_cond_noise_aug_for_inference: float = MINIMAX_H3_IMGVID_COND_TIMESTEP,
|
||||
audio_cond_noise_aug_for_inference: float = MINIMAX_H3_AUDIO_REF_COND_TIMESTEP,
|
||||
on_step: Callable[[int, torch.Tensor, torch.Tensor], None] | None = None,
|
||||
step_profiler: Callable[[int], AbstractContextManager] | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run the full denoise loop; returns final (video_rows, audio_rows).
|
||||
|
||||
``initial_video_rows`` covers all image rows of the positive layout. For a
|
||||
conditional task, pass ``keyframe_cond_rows`` and/or ``audio_ref_rows`` to
|
||||
pin those rows across every step. The model's raw positive velocity is the
|
||||
update signal; MiniMax H3 only supports cfg-distilled checkpoints.
|
||||
``model_forward`` is the native-stage hook for residency/BCG runners and
|
||||
receives the zero-based loop step; the default keeps this helper
|
||||
independently testable with a plain callable.
|
||||
"""
|
||||
if len(sigmas_video) != len(sigmas_audio):
|
||||
raise ValueError("video/audio sigma schedules must have equal length")
|
||||
if len(sigmas_video) < 2:
|
||||
raise ValueError("sigma schedules need at least 2 entries")
|
||||
n_cond = positive.video_target_start
|
||||
if keyframe_cond_rows is None:
|
||||
if n_cond != 0:
|
||||
raise ValueError(
|
||||
f"layout has {n_cond} cond rows but keyframe_cond_rows is None"
|
||||
)
|
||||
else:
|
||||
if int(keyframe_cond_rows.shape[0]) != n_cond:
|
||||
raise ValueError(
|
||||
f"keyframe_cond_rows {int(keyframe_cond_rows.shape[0])} != "
|
||||
f"layout cond rows {n_cond}"
|
||||
)
|
||||
video_rows = initial_video_rows.to(device=device, dtype=torch.float32, copy=True)
|
||||
audio_rows = initial_audio_rows.to(device=device, dtype=torch.float32, copy=True)
|
||||
if int(video_rows.shape[0]) != int(positive.img_pos.shape[0]):
|
||||
raise ValueError(
|
||||
f"initial video rows {int(video_rows.shape[0])} != positive layout "
|
||||
f"rows {int(positive.img_pos.shape[0])}"
|
||||
)
|
||||
if int(audio_rows.shape[0]) != int(positive.audio_pos.shape[0]):
|
||||
raise ValueError(
|
||||
f"initial audio rows {int(audio_rows.shape[0])} != positive layout "
|
||||
f"rows {int(positive.audio_pos.shape[0])}"
|
||||
)
|
||||
n_audio_ref = positive.audio_target_start
|
||||
if audio_ref_rows is None:
|
||||
if n_audio_ref != 0:
|
||||
raise ValueError(
|
||||
f"layout has {n_audio_ref} audio ref rows but audio_ref_rows is None"
|
||||
)
|
||||
audio_anchor = None
|
||||
else:
|
||||
if int(audio_ref_rows.shape[0]) != n_audio_ref:
|
||||
raise ValueError(
|
||||
f"audio_ref_rows {int(audio_ref_rows.shape[0])} != layout "
|
||||
f"audio ref rows {n_audio_ref}"
|
||||
)
|
||||
audio_anchor = audio_ref_rows.to(device=device, dtype=torch.float32)
|
||||
cond_anchor = (
|
||||
keyframe_cond_rows.to(device=device, dtype=torch.float32)
|
||||
if keyframe_cond_rows is not None
|
||||
else None
|
||||
)
|
||||
if cond_anchor is not None:
|
||||
video_rows.index_copy_(0, positive.cond_row_idx, cond_anchor)
|
||||
if audio_anchor is not None:
|
||||
audio_rows.index_copy_(0, positive.audio_ref_row_idx, audio_anchor)
|
||||
|
||||
num_steps = len(sigmas_video) - 1
|
||||
video_target_slice = positive.video_target_slice
|
||||
audio_target_slice = positive.audio_target_slice
|
||||
video_timesteps = [1.0 - sigma for sigma in sigmas_video[:-1]]
|
||||
audio_timesteps = [1.0 - sigma for sigma in sigmas_audio[:-1]]
|
||||
# One H2D copy per schedule, preserving the previous Python-float
|
||||
# subtraction followed by fp32 conversion.
|
||||
video_step_t = torch.tensor(video_timesteps, dtype=torch.float32, device=device)
|
||||
audio_step_t = torch.tensor(audio_timesteps, dtype=torch.float32, device=device)
|
||||
timestep_plan = positive.prepare_timestep_plan(
|
||||
video_timesteps=video_timesteps,
|
||||
audio_timesteps=audio_timesteps,
|
||||
imgvid_cond_noise_aug=float(imgvid_cond_noise_aug_for_inference),
|
||||
audio_ref_cond_noise_aug=float(audio_cond_noise_aug_for_inference),
|
||||
)
|
||||
# match the scheduler's device-fp32 math once, then reuse one denoised
|
||||
# scratch per modality instead of allocating intermediates every step
|
||||
video_sigmas = torch.tensor(sigmas_video, dtype=torch.float32, device=device)
|
||||
audio_sigmas = torch.tensor(sigmas_audio, dtype=torch.float32, device=device)
|
||||
video_sigma_ratios = video_sigmas[1:] / video_sigmas[:-1]
|
||||
audio_sigma_ratios = audio_sigmas[1:] / audio_sigmas[:-1]
|
||||
video_sigma_t = 1.0 - video_step_t
|
||||
audio_sigma_t = 1.0 - audio_step_t
|
||||
video_one_minus_sigma_ratios = 1.0 - video_sigma_ratios
|
||||
audio_one_minus_sigma_ratios = 1.0 - audio_sigma_ratios
|
||||
video_denoised_scratch = torch.empty_like(video_rows[video_target_slice])
|
||||
audio_denoised_scratch = torch.empty_like(audio_rows[audio_target_slice])
|
||||
for step in range(num_steps):
|
||||
step_cm = step_profiler(step) if step_profiler is not None else nullcontext()
|
||||
with step_cm:
|
||||
s_v = sigmas_video[step]
|
||||
s_a = sigmas_audio[step]
|
||||
|
||||
fk = positive.forward_kwargs(
|
||||
video_rows=video_rows,
|
||||
audio_rows=audio_rows,
|
||||
step_timesteps=timestep_plan[step],
|
||||
)
|
||||
with torch.inference_mode():
|
||||
if model_forward is None:
|
||||
v_video, v_audio = model(**fk)
|
||||
else:
|
||||
v_video, v_audio = model_forward(model, fk, step)
|
||||
# The model outputs are inference tensors. Keep their disposable
|
||||
# fp32 velocity updates in the same context so ``out=velocity``
|
||||
# can reuse the output storage without an extra clone.
|
||||
mv_video_t = v_video.float()
|
||||
mv_audio_t = v_audio[audio_target_slice].float()
|
||||
|
||||
video_target = video_rows[video_target_slice]
|
||||
_minimax_h3_update_target_rows_(
|
||||
video_target,
|
||||
mv_video_t,
|
||||
sigma_t=video_sigma_t[step],
|
||||
sigma_curr=s_v,
|
||||
sigma_ratio=video_sigma_ratios[step],
|
||||
one_minus_sigma_ratio=video_one_minus_sigma_ratios[step],
|
||||
denoised_scratch=video_denoised_scratch,
|
||||
)
|
||||
|
||||
audio_target = audio_rows[audio_target_slice]
|
||||
_minimax_h3_update_target_rows_(
|
||||
audio_target,
|
||||
mv_audio_t,
|
||||
sigma_t=audio_sigma_t[step],
|
||||
sigma_curr=s_a,
|
||||
sigma_ratio=audio_sigma_ratios[step],
|
||||
one_minus_sigma_ratio=audio_one_minus_sigma_ratios[step],
|
||||
denoised_scratch=audio_denoised_scratch,
|
||||
)
|
||||
if on_step is not None:
|
||||
on_step(step, video_rows, audio_rows)
|
||||
|
||||
return video_rows, audio_rows
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_AUDIO_REF_COND_TIMESTEP",
|
||||
"MINIMAX_H3_AUDIO_ROW_WIDTH",
|
||||
"MINIMAX_H3_IMGVID_COND_TIMESTEP",
|
||||
"MINIMAX_H3_VIDEO_ROW_WIDTH",
|
||||
"MiniMaxH3DenoiseBranch",
|
||||
"minimax_h3_denoise_loop",
|
||||
]
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 keyframe (imgvid) condition encoding.
|
||||
|
||||
Condition anchor row recipe:
|
||||
|
||||
- ``video_vae.encode_images(PIL, use_fp16_latent=True)`` under a
|
||||
scoped seed-42 RNG fork — the DiagonalGaussian is SAMPLED
|
||||
(use_mean=False) with seed 42, so the seed is part of
|
||||
the contract, not a convenience
|
||||
- normalize ``(z - latents_mean) / latents_std`` with the loader-injected
|
||||
``MiniMaxH3VideoVAEArchConfig`` values
|
||||
- patchify [1, 2, 2] into packed cond rows, fp32
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEArchConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_tokens import (
|
||||
minimax_h3_patchify_video_latent,
|
||||
)
|
||||
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED = 42
|
||||
MINIMAX_H3_KEYFRAME_PATCH_SIZE = (1, 2, 2)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def minimax_h3_scoped_encode_rng(seed: int, device: torch.device | None = None):
|
||||
"""Seed torch RNGs for a deterministic sampled VAE encode without leaking state.
|
||||
|
||||
The encode recipes seed the default torch generators right before a
|
||||
posterior-sampled VAE encode. Forking restores the process-global CPU and
|
||||
CUDA generators after the encode while preserving the exact sampled result.
|
||||
"""
|
||||
devices: list[torch.device] = []
|
||||
if device is not None and device.type == "cuda" and torch.cuda.is_available():
|
||||
devices = [device]
|
||||
with torch.random.fork_rng(devices=devices):
|
||||
torch.default_generator.manual_seed(int(seed))
|
||||
for forked_device in devices:
|
||||
with torch.cuda.device(forked_device):
|
||||
torch.cuda.manual_seed(int(seed))
|
||||
yield
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def minimax_h3_scoped_encode_fp32(video_vae: Any):
|
||||
"""Scope the video VAE to fp32 for one or more keyframe/reference encodes.
|
||||
|
||||
encode_keyframe_cond_rows and encode_reference_video_rows each also guard
|
||||
their own cast (skipping it when already fp32), so nesting this around a
|
||||
caller that does more than one encode -- FL2VA's two keyframes, ref2va's
|
||||
image reference plus video reference -- turns their per-call casts into
|
||||
no-ops instead of toggling the whole VAE's dtype once per encode.
|
||||
"""
|
||||
parameter = next(video_vae.parameters())
|
||||
prev_dtype = parameter.dtype
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(torch.float32)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(prev_dtype)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _cached_latent_mean_std(
|
||||
mean_values: tuple[float, ...],
|
||||
std_values: tuple[float, ...],
|
||||
view_shape: tuple[int, ...],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""CPU mean/std tensors for a fixed (values, shape) triple, built once.
|
||||
|
||||
arch_config.latents_mean/std are static config values fixed for the
|
||||
life of a loaded VAE, so every call with the same values reconstructs
|
||||
an identical tensor; cache it instead of rebuilding on every encode.
|
||||
"""
|
||||
mean = torch.tensor(mean_values).view(view_shape)
|
||||
std = torch.tensor(std_values).view(view_shape)
|
||||
return mean, std
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def minimax_h3_encode_keyframe_cond_rows(
|
||||
video_vae: Any,
|
||||
image: Any,
|
||||
arch_config: MiniMaxH3VideoVAEArchConfig,
|
||||
) -> torch.Tensor:
|
||||
"""Encode a target-canvas PIL image into packed imgvid cond rows.
|
||||
|
||||
Returns [n_rows, 24 * patch_h * patch_w] fp32 on CPU.
|
||||
"""
|
||||
seed = MINIMAX_H3_KEYFRAME_ENCODE_SEED
|
||||
# The encode recipe runs on fp32 weights. Normal H3 residency already keeps
|
||||
# the shared video VAE in fp32; retain the scoped cast for standalone use.
|
||||
parameter = next(video_vae.parameters())
|
||||
prev_dtype = parameter.dtype
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(torch.float32)
|
||||
try:
|
||||
with minimax_h3_scoped_encode_rng(seed, parameter.device):
|
||||
z = video_vae.encode_images(image, use_fp16_latent=True)[0]
|
||||
finally:
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(prev_dtype)
|
||||
z = z.cpu().float()
|
||||
if z.dim() == 4:
|
||||
z = z[None]
|
||||
latent_channels = arch_config.latent_channels
|
||||
if z.dim() != 5 or int(z.shape[1]) != latent_channels:
|
||||
raise ValueError(f"unexpected imgvid latent shape {list(z.shape)}")
|
||||
mean, std = _cached_latent_mean_std(
|
||||
tuple(arch_config.latents_mean),
|
||||
tuple(arch_config.latents_std),
|
||||
(1, latent_channels, 1, 1, 1),
|
||||
)
|
||||
z.sub_(mean).div_(std)
|
||||
rows = minimax_h3_patchify_video_latent(
|
||||
z, patch_size=list(MINIMAX_H3_KEYFRAME_PATCH_SIZE)
|
||||
)
|
||||
return rows.to(torch.float32)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_KEYFRAME_ENCODE_SEED",
|
||||
"MINIMAX_H3_KEYFRAME_PATCH_SIZE",
|
||||
"_cached_latent_mean_std",
|
||||
"minimax_h3_encode_keyframe_cond_rows",
|
||||
"minimax_h3_scoped_encode_rng",
|
||||
"minimax_h3_scoped_encode_fp32",
|
||||
]
|
||||
+913
@@ -0,0 +1,913 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Request-owned material URI localization for the MiniMax H3 pipeline.
|
||||
|
||||
The canonical MiniMax H3 contract intentionally carries semantic URIs rather
|
||||
than worker-local paths. Direct media consumers (Pillow, ffmpeg and
|
||||
torchaudio) cannot consume every URI scheme in that contract, so localization
|
||||
belongs at the model-specific material boundary. Materialized sources and
|
||||
derived work directories are registered on ``Req.extra`` and explicitly
|
||||
released by the encoder stages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
MINIMAX_H3_MATERIAL_CACHE_EXTRA_KEY = "minimax_h3_material_localization"
|
||||
MINIMAX_H3_MATERIAL_PROBE_EXTRA_KEY = "minimax_h3_material_probe_facts"
|
||||
MINIMAX_H3_TEMP_DIRS_EXTRA_KEY = "minimax_h3_request_temp_dirs"
|
||||
MINIMAX_H3_HTTP_READ_CHUNK_BYTES = 1024 * 1024
|
||||
MINIMAX_H3_BASE64_DECODE_CHUNK_CHARS = 1024 * 1024
|
||||
MINIMAX_H3_BASE64_HEADER_MAX_CHARS = 4 * 1024
|
||||
MINIMAX_H3_TAR_HEADER_MAX_ENCODED_CHARS = 64 * 1024
|
||||
|
||||
_BASE64_ALPHABET = frozenset(
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-"
|
||||
)
|
||||
|
||||
_DEFAULT_SUFFIX_BY_TYPE = {
|
||||
"image": ".png",
|
||||
"video": ".mp4",
|
||||
"video_audio": ".mp4",
|
||||
"audio": ".wav",
|
||||
}
|
||||
_SUFFIX_BY_MEDIA_TYPE = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"video/mp4": ".mp4",
|
||||
"video/quicktime": ".mov",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/flac": ".flac",
|
||||
}
|
||||
|
||||
|
||||
def minimax_h3_register_temp_dir(batch: Any, path: str, *, owner: str) -> str:
|
||||
"""Register one request-owned directory and return *path* unchanged."""
|
||||
|
||||
registry = batch.extra.setdefault(MINIMAX_H3_TEMP_DIRS_EXTRA_KEY, {})
|
||||
paths = registry.setdefault(str(owner), [])
|
||||
normalized = str(path)
|
||||
if normalized not in paths:
|
||||
paths.append(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def minimax_h3_cleanup_temp_dirs(
|
||||
batch: Any, *, owners: Iterable[str] | None = None
|
||||
) -> None:
|
||||
"""Remove registered request directories, tolerating repeated cleanup."""
|
||||
|
||||
registry = batch.extra.get(MINIMAX_H3_TEMP_DIRS_EXTRA_KEY)
|
||||
selected = (
|
||||
list(registry)
|
||||
if owners is None and isinstance(registry, dict)
|
||||
else [str(owner) for owner in (owners or ())]
|
||||
)
|
||||
if isinstance(registry, dict):
|
||||
for owner in selected:
|
||||
paths = registry.pop(owner, [])
|
||||
if isinstance(paths, (list, tuple)):
|
||||
for path in paths:
|
||||
shutil.rmtree(str(path), ignore_errors=True)
|
||||
if not registry:
|
||||
batch.extra.pop(MINIMAX_H3_TEMP_DIRS_EXTRA_KEY, None)
|
||||
if owners is None or "material" in selected:
|
||||
batch.extra.pop(MINIMAX_H3_MATERIAL_CACHE_EXTRA_KEY, None)
|
||||
batch.extra.pop(MINIMAX_H3_MATERIAL_PROBE_EXTRA_KEY, None)
|
||||
|
||||
|
||||
def _base64_uri_payload_start(uri: str) -> tuple[int, str | None]:
|
||||
media_type = None
|
||||
if uri.startswith("data:"):
|
||||
separator = uri.find(",")
|
||||
if separator < 0:
|
||||
raise ValueError("data URI must contain a comma separator")
|
||||
if separator > MINIMAX_H3_BASE64_HEADER_MAX_CHARS:
|
||||
raise ValueError("data URI header is too large")
|
||||
header = uri[:separator]
|
||||
if ";base64" not in header:
|
||||
raise ValueError("data URI must use ;base64 encoding")
|
||||
media_type = header[5:].split(";", 1)[0].lower() or None
|
||||
payload_start = separator + 1
|
||||
elif uri.startswith("base64://"):
|
||||
payload_start = len("base64://")
|
||||
separator = uri.find(",", payload_start)
|
||||
if separator >= 0:
|
||||
if separator - payload_start > MINIMAX_H3_BASE64_HEADER_MAX_CHARS:
|
||||
raise ValueError("base64 URI header is too large")
|
||||
header = uri[payload_start:separator]
|
||||
media_type = header.split(";", 1)[0].lower() or None
|
||||
payload_start = separator + 1
|
||||
else: # pragma: no cover - guarded by the caller
|
||||
raise ValueError("not a base64 material URI")
|
||||
return payload_start, media_type
|
||||
|
||||
|
||||
def _iter_base64_payload_bytes(uri: str, payload_start: int):
|
||||
"""Yield validated, unquoted base64 bytes without copying the payload."""
|
||||
|
||||
index = payload_start
|
||||
while index < len(uri):
|
||||
character = uri[index]
|
||||
if character == "%":
|
||||
if index + 2 >= len(uri):
|
||||
raise ValueError("material URI has an invalid percent escape")
|
||||
try:
|
||||
value = int(uri[index + 1 : index + 3], 16)
|
||||
except ValueError as exc:
|
||||
raise ValueError("material URI has an invalid percent escape") from exc
|
||||
index += 3
|
||||
character = chr(value)
|
||||
else:
|
||||
index += 1
|
||||
|
||||
if character.isspace():
|
||||
continue
|
||||
if len(character) != 1 or ord(character) > 127:
|
||||
raise ValueError("material URI base64 payload must be ASCII")
|
||||
value = ord(character)
|
||||
if value not in _BASE64_ALPHABET:
|
||||
raise ValueError(
|
||||
f"material URI has an invalid base64 character {character!r}"
|
||||
)
|
||||
yield value
|
||||
|
||||
|
||||
def _parse_tar_member_uri(uri: str) -> tuple[Path, int, int, str | None]:
|
||||
if uri.startswith("tar+offset://"):
|
||||
prefix = "tar+offset://"
|
||||
elif uri.startswith("tar+b64header://"):
|
||||
prefix = "tar+b64header://"
|
||||
else:
|
||||
raise ValueError("unsupported tar material URI")
|
||||
try:
|
||||
tar_path, encoded_header = uri[len(prefix) :].rsplit(":", 1)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"tar material URI must contain '<tar_path>:<encoded_header>'"
|
||||
) from exc
|
||||
if len(encoded_header) > MINIMAX_H3_TAR_HEADER_MAX_ENCODED_CHARS:
|
||||
raise ValueError("tar material URI encoded header is too large")
|
||||
padded = encoded_header + "=" * (-len(encoded_header) % 4)
|
||||
try:
|
||||
header = json.loads(
|
||||
base64.b64decode(
|
||||
padded.encode("ascii"), altchars=b"-_", validate=True
|
||||
).decode("utf-8")
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError("tar material URI has an invalid encoded header") from exc
|
||||
if not isinstance(header, dict):
|
||||
raise ValueError("tar material URI header must be a JSON object")
|
||||
if header.get("schema") != "sglang.tar_member_ref/v1":
|
||||
raise ValueError(
|
||||
f"unsupported tar material header schema: {header.get('schema')!r}"
|
||||
)
|
||||
try:
|
||||
offset = int(header["offset_data"])
|
||||
size = int(header["size"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"tar material header requires integer offset_data and size"
|
||||
) from exc
|
||||
if offset < 0 or size < 0:
|
||||
raise ValueError("tar material offset_data and size must be non-negative")
|
||||
return (
|
||||
Path(tar_path).expanduser(),
|
||||
offset,
|
||||
size,
|
||||
str(header.get("member") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
def _safe_suffix(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
suffix = Path(urllib.parse.urlsplit(value).path).suffix.lower()
|
||||
if suffix and len(suffix) <= 10 and suffix[1:].isalnum():
|
||||
return suffix
|
||||
return None
|
||||
|
||||
|
||||
def _checked_material_file(path: Path, *, label: str) -> str:
|
||||
"""Validate that a localized source exists and is non-empty."""
|
||||
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"{label} does not exist or is not a file: {path}")
|
||||
if path.stat().st_size <= 0:
|
||||
raise ValueError(f"{label} is empty: {path}")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _parse_frame_rate(value: Any) -> float:
|
||||
if value in {None, "", "N/A", "0/0"}:
|
||||
return 0.0
|
||||
raw = str(value)
|
||||
try:
|
||||
if "/" in raw:
|
||||
numerator, denominator = raw.split("/", 1)
|
||||
denominator_value = float(denominator)
|
||||
parsed = float(numerator) / denominator_value if denominator_value else 0.0
|
||||
else:
|
||||
parsed = float(raw)
|
||||
return parsed if math.isfinite(parsed) else 0.0
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse_display_ratio(value: Any) -> float:
|
||||
if value in {None, "", "N/A", "0:1", "0/1"}:
|
||||
return 0.0
|
||||
raw = str(value).strip()
|
||||
separator = ":" if ":" in raw else "/" if "/" in raw else None
|
||||
try:
|
||||
if separator is None:
|
||||
ratio = float(raw)
|
||||
else:
|
||||
numerator, denominator = raw.split(separator, 1)
|
||||
ratio = float(numerator) / float(denominator)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return 0.0
|
||||
return ratio if math.isfinite(ratio) and ratio > 0 else 0.0
|
||||
|
||||
|
||||
def _stream_rotation_degrees(stream: dict[str, Any]) -> float:
|
||||
values: list[Any] = []
|
||||
side_data = stream.get("side_data_list")
|
||||
if isinstance(side_data, list):
|
||||
values.extend(
|
||||
item.get("rotation") for item in side_data if isinstance(item, dict)
|
||||
)
|
||||
tags = stream.get("tags")
|
||||
if isinstance(tags, dict):
|
||||
values.append(tags.get("rotate"))
|
||||
for value in values:
|
||||
if value in {None, "", "N/A"}:
|
||||
continue
|
||||
try:
|
||||
rotation = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if math.isfinite(rotation):
|
||||
return rotation % 360.0
|
||||
return 0.0
|
||||
|
||||
|
||||
def _display_geometry(stream: dict[str, Any]) -> tuple[float, float, float, float]:
|
||||
"""Return square-pixel display width/height, SAR, and rotation."""
|
||||
|
||||
coded_width = int(stream.get("width") or 0)
|
||||
coded_height = int(stream.get("height") or 0)
|
||||
sar = _parse_display_ratio(stream.get("sample_aspect_ratio")) or 1.0
|
||||
dar = _parse_display_ratio(stream.get("display_aspect_ratio"))
|
||||
physical_height = float(coded_height)
|
||||
physical_width = dar * physical_height if dar > 0.0 else float(coded_width) * sar
|
||||
rotation = _stream_rotation_degrees(stream)
|
||||
quarter_turns = round(rotation / 90.0)
|
||||
if abs(rotation - quarter_turns * 90.0) <= 1e-6:
|
||||
if quarter_turns % 2:
|
||||
return physical_height, physical_width, sar, rotation
|
||||
return physical_width, physical_height, sar, rotation
|
||||
radians = math.radians(rotation)
|
||||
cosine = abs(math.cos(radians))
|
||||
sine = abs(math.sin(radians))
|
||||
display_width = physical_width * cosine + physical_height * sine
|
||||
display_height = physical_width * sine + physical_height * cosine
|
||||
return display_width, display_height, sar, rotation
|
||||
|
||||
|
||||
_FFPROBE_STREAM_ENTRIES = (
|
||||
"stream=codec_type,width,height,duration,sample_rate,channels,"
|
||||
"avg_frame_rate,r_frame_rate,nb_frames,sample_aspect_ratio,display_aspect_ratio"
|
||||
":stream_tags=rotate"
|
||||
)
|
||||
|
||||
# ffprobe gained the per-stream "stream_side_data" section in 6.0 and rejects
|
||||
# the whole -show_entries spec without it. Older builds (e.g. Ubuntu 22.04's
|
||||
# 4.4) report rotation through the "rotate" stream tag, which
|
||||
# _stream_rotation_degrees already reads, so drop the section on fallback.
|
||||
_FFPROBE_ENTRY_VARIANTS = (
|
||||
f"{_FFPROBE_STREAM_ENTRIES}:stream_side_data=rotation:format=format_name,duration",
|
||||
f"{_FFPROBE_STREAM_ENTRIES}:format=format_name,duration",
|
||||
)
|
||||
_ffprobe_entries: str | None = None
|
||||
|
||||
|
||||
def _ffprobe_media(path: str) -> dict[str, Any]:
|
||||
global _ffprobe_entries
|
||||
|
||||
variants = (
|
||||
(_ffprobe_entries,) if _ffprobe_entries is not None else _FFPROBE_ENTRY_VARIANTS
|
||||
)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for entries in variants:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
"file",
|
||||
"-format_whitelist",
|
||||
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,wav,mp3,flac,ogg",
|
||||
"-show_entries",
|
||||
entries,
|
||||
"-of",
|
||||
"json",
|
||||
"-i",
|
||||
path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Only an unknown section name is worth retrying; a genuinely bad
|
||||
# input must fail on the first variant.
|
||||
if "No match for section" not in (exc.stderr or ""):
|
||||
raise
|
||||
last_error = exc
|
||||
continue
|
||||
_ffprobe_entries = entries
|
||||
return json.loads(result.stdout)
|
||||
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
|
||||
def _validate_localized_media(
|
||||
path: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe one localized source and return facts used by MiniMax H3 admission.
|
||||
|
||||
This is deliberately a model-facing validity check: it verifies that the
|
||||
source is non-empty, parseable and contains the stream type requested by
|
||||
the condition. Generic transport/resource ceilings do not belong here;
|
||||
the model's shape and temporal contracts are resolved separately.
|
||||
"""
|
||||
|
||||
if condition_type == "image":
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
with Image.open(path) as image:
|
||||
coded_width, coded_height = image.size
|
||||
image_format = str(image.format or "").upper()
|
||||
if coded_width <= 0 or coded_height <= 0:
|
||||
raise ValueError("image has no positive dimensions")
|
||||
display_image = ImageOps.exif_transpose(image)
|
||||
width, height = display_image.size
|
||||
except Exception as exc:
|
||||
raise ValueError("MiniMax H3 image material is invalid") from exc
|
||||
if image_format not in {"JPEG", "PNG", "WEBP"}:
|
||||
raise ValueError("MiniMax H3 image material uses an unsupported format")
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
"MiniMax H3 image material has no positive display geometry"
|
||||
)
|
||||
return {
|
||||
"condition_type": "image",
|
||||
"coded_width": int(coded_width),
|
||||
"coded_height": int(coded_height),
|
||||
"display_width": int(width),
|
||||
"display_height": int(height),
|
||||
"image_format": image_format,
|
||||
"exif_transposed": (coded_width, coded_height) != (width, height),
|
||||
}
|
||||
|
||||
if condition_type not in {"audio", "video", "video_audio"}:
|
||||
raise ValueError(f"unsupported MiniMax H3 condition type {condition_type!r}")
|
||||
try:
|
||||
payload = _ffprobe_media(path)
|
||||
except Exception as exc:
|
||||
raise ValueError("MiniMax H3 media material is invalid") from exc
|
||||
|
||||
streams = payload.get("streams") or []
|
||||
format_names = set(
|
||||
str((payload.get("format") or {}).get("format_name") or "").split(",")
|
||||
)
|
||||
allowed_formats = {
|
||||
"mov",
|
||||
"mp4",
|
||||
"m4a",
|
||||
"3gp",
|
||||
"3g2",
|
||||
"mj2",
|
||||
"matroska",
|
||||
"webm",
|
||||
"wav",
|
||||
"mp3",
|
||||
"flac",
|
||||
"ogg",
|
||||
}
|
||||
if not format_names or not format_names.issubset(allowed_formats):
|
||||
raise ValueError("MiniMax H3 media container format is not allowed")
|
||||
video_streams = [s for s in streams if s.get("codec_type") == "video"]
|
||||
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
|
||||
if condition_type in {"video", "video_audio"} and not video_streams:
|
||||
raise ValueError("MiniMax H3 video material has no video stream")
|
||||
if condition_type in {"audio", "video_audio"} and not audio_streams:
|
||||
raise ValueError("MiniMax H3 audio material has no audio stream")
|
||||
|
||||
primary_video: dict[str, Any] | None = None
|
||||
for stream in video_streams:
|
||||
try:
|
||||
width = int(stream.get("width") or 0)
|
||||
height = int(stream.get("height") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"MiniMax H3 video material has invalid dimensions"
|
||||
) from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("MiniMax H3 video material has no positive dimensions")
|
||||
fps = _parse_frame_rate(stream.get("avg_frame_rate")) or _parse_frame_rate(
|
||||
stream.get("r_frame_rate")
|
||||
)
|
||||
if fps <= 0:
|
||||
raise ValueError("MiniMax H3 video material has no usable frame rate")
|
||||
if primary_video is None:
|
||||
primary_video = stream
|
||||
|
||||
for stream in audio_streams:
|
||||
try:
|
||||
sample_rate = int(stream.get("sample_rate") or 0)
|
||||
channels = int(stream.get("channels") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("MiniMax H3 audio material has invalid metadata") from exc
|
||||
if sample_rate <= 0:
|
||||
raise ValueError("MiniMax H3 audio material has no usable sample rate")
|
||||
if channels <= 0:
|
||||
raise ValueError("MiniMax H3 audio material has no usable channel count")
|
||||
|
||||
durations: list[float] = []
|
||||
for value in [
|
||||
(payload.get("format") or {}).get("duration"),
|
||||
*(stream.get("duration") for stream in streams),
|
||||
]:
|
||||
if value in {None, "", "N/A"}:
|
||||
continue
|
||||
try:
|
||||
duration = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if math.isfinite(duration) and duration > 0:
|
||||
durations.append(duration)
|
||||
if not durations:
|
||||
raise ValueError("MiniMax H3 media material has no positive duration")
|
||||
|
||||
duration_seconds = max(durations)
|
||||
facts: dict[str, Any] = {
|
||||
"condition_type": condition_type,
|
||||
"duration_seconds": duration_seconds,
|
||||
"has_audio": bool(audio_streams),
|
||||
}
|
||||
if primary_video is not None:
|
||||
coded_width = int(primary_video.get("width") or 0)
|
||||
coded_height = int(primary_video.get("height") or 0)
|
||||
display_width, display_height, sar, rotation = _display_geometry(primary_video)
|
||||
fps = _parse_frame_rate(primary_video.get("avg_frame_rate"))
|
||||
if fps <= 0:
|
||||
fps = _parse_frame_rate(primary_video.get("r_frame_rate"))
|
||||
raw_count = primary_video.get("nb_frames")
|
||||
try:
|
||||
frame_count = int(raw_count)
|
||||
except (TypeError, ValueError):
|
||||
frame_count = max(1, int(round(duration_seconds * fps)))
|
||||
if frame_count <= 0:
|
||||
frame_count = max(1, int(round(duration_seconds * fps)))
|
||||
try:
|
||||
video_duration_seconds = float(primary_video.get("duration"))
|
||||
except (TypeError, ValueError):
|
||||
video_duration_seconds = 0.0
|
||||
if not math.isfinite(video_duration_seconds) or video_duration_seconds <= 0:
|
||||
video_duration_seconds = frame_count / fps
|
||||
facts.update(
|
||||
{
|
||||
"coded_width": coded_width,
|
||||
"coded_height": coded_height,
|
||||
"display_width": display_width,
|
||||
"display_height": display_height,
|
||||
"sample_aspect_ratio": str(
|
||||
primary_video.get("sample_aspect_ratio") or "1:1"
|
||||
),
|
||||
"sample_aspect_ratio_value": sar,
|
||||
"display_aspect_ratio": display_width / display_height,
|
||||
"rotation_degrees": rotation,
|
||||
"fps": fps,
|
||||
"frame_count": frame_count,
|
||||
"video_duration_seconds": video_duration_seconds,
|
||||
}
|
||||
)
|
||||
if audio_streams:
|
||||
facts["audio_sample_rate"] = int(audio_streams[0].get("sample_rate") or 0)
|
||||
facts["audio_channels"] = int(audio_streams[0].get("channels") or 0)
|
||||
try:
|
||||
audio_duration_seconds = float(audio_streams[0].get("duration"))
|
||||
except (TypeError, ValueError):
|
||||
audio_duration_seconds = 0.0
|
||||
facts["audio_duration_seconds"] = (
|
||||
audio_duration_seconds
|
||||
if math.isfinite(audio_duration_seconds) and audio_duration_seconds > 0
|
||||
else duration_seconds
|
||||
)
|
||||
return facts
|
||||
|
||||
|
||||
def _validate_material_once(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
path: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
) -> dict[str, Any]:
|
||||
probe_facts = batch.extra.setdefault(MINIMAX_H3_MATERIAL_PROBE_EXTRA_KEY, {})
|
||||
key = (uri, condition_type)
|
||||
cached = probe_facts.get(key)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
facts = _validate_localized_media(path, condition_type=condition_type)
|
||||
if not isinstance(facts, dict):
|
||||
raise ValueError("MiniMax H3 material probe did not return facts")
|
||||
probe_facts[key] = facts
|
||||
return facts
|
||||
|
||||
|
||||
def _material_output_paths(
|
||||
batch: Any,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
source_name: str | None = None,
|
||||
media_type: str | None = None,
|
||||
) -> tuple[Path, Path]:
|
||||
suffix = (
|
||||
_safe_suffix(source_name)
|
||||
or _SUFFIX_BY_MEDIA_TYPE.get(str(media_type or "").lower())
|
||||
or _DEFAULT_SUFFIX_BY_TYPE.get(condition_type, ".bin")
|
||||
)
|
||||
output_path = (
|
||||
Path(_material_workdir(batch)) / f"condition_{int(condition_index):04d}{suffix}"
|
||||
)
|
||||
return output_path, output_path.with_name(output_path.name + ".partial")
|
||||
|
||||
|
||||
def _material_workdir(batch: Any) -> str:
|
||||
registry = batch.extra.setdefault(MINIMAX_H3_TEMP_DIRS_EXTRA_KEY, {})
|
||||
material_dirs = registry.get("material")
|
||||
if isinstance(material_dirs, list) and material_dirs:
|
||||
return str(material_dirs[0])
|
||||
return minimax_h3_register_temp_dir(
|
||||
batch,
|
||||
tempfile.mkdtemp(prefix="minimax_h3_material_"),
|
||||
owner="material",
|
||||
)
|
||||
|
||||
|
||||
def _decode_base64_chunk(encoded: bytes | bytearray) -> bytes:
|
||||
try:
|
||||
return base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||
except Exception as exc:
|
||||
raise ValueError("material URI has an invalid base64 payload") from exc
|
||||
|
||||
|
||||
def _stream_base64_material(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
) -> str:
|
||||
payload_start, media_type = _base64_uri_payload_start(uri)
|
||||
|
||||
output_path, partial_path = _material_output_paths(
|
||||
batch,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
media_type=media_type,
|
||||
)
|
||||
total = 0
|
||||
encoded_size = 0
|
||||
padding = 0
|
||||
saw_padding = False
|
||||
encoded_chunk = bytearray()
|
||||
try:
|
||||
with partial_path.open("wb") as output:
|
||||
for value in _iter_base64_payload_bytes(uri, payload_start):
|
||||
encoded_size += 1
|
||||
if value == ord("="):
|
||||
saw_padding = True
|
||||
padding += 1
|
||||
if padding > 2:
|
||||
raise ValueError("material URI has invalid base64 padding")
|
||||
elif saw_padding:
|
||||
raise ValueError("material URI has data after base64 padding")
|
||||
encoded_chunk.append(value)
|
||||
if len(encoded_chunk) == MINIMAX_H3_BASE64_DECODE_CHUNK_CHARS:
|
||||
decoded_chunk = _decode_base64_chunk(encoded_chunk)
|
||||
total += len(decoded_chunk)
|
||||
output.write(decoded_chunk)
|
||||
encoded_chunk.clear()
|
||||
if encoded_size == 0:
|
||||
raise ValueError("material URI base64 payload is empty")
|
||||
if encoded_size % 4 == 1 or (padding and encoded_size % 4):
|
||||
raise ValueError("material URI has an invalid base64 payload length")
|
||||
if encoded_chunk:
|
||||
encoded_chunk.extend(b"=" * (-len(encoded_chunk) % 4))
|
||||
decoded_chunk = _decode_base64_chunk(encoded_chunk)
|
||||
total += len(decoded_chunk)
|
||||
output.write(decoded_chunk)
|
||||
decoded_size = (encoded_size * 3) // 4 - padding
|
||||
if decoded_size <= 0:
|
||||
raise ValueError("material URI decoded payload is empty")
|
||||
if total != decoded_size:
|
||||
raise ValueError(
|
||||
f"MiniMax H3 base64 decoded size {total} != expected {decoded_size}"
|
||||
)
|
||||
partial_path.replace(output_path)
|
||||
except Exception:
|
||||
partial_path.unlink(missing_ok=True)
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def _stream_tar_member_material(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
) -> str:
|
||||
source_path, offset, size, member = _parse_tar_member_uri(uri)
|
||||
if size <= 0:
|
||||
raise ValueError("tar material payload is empty")
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"tar material source does not exist or is not a file: {source_path}"
|
||||
)
|
||||
source_size = source_path.stat().st_size
|
||||
if offset > source_size or size > source_size - offset:
|
||||
available = max(0, source_size - offset)
|
||||
raise ValueError(
|
||||
f"tar material payload is truncated: expected {size} bytes, "
|
||||
f"only {available} available"
|
||||
)
|
||||
|
||||
output_path, partial_path = _material_output_paths(
|
||||
batch,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
source_name=member,
|
||||
)
|
||||
remaining = size
|
||||
try:
|
||||
with source_path.open("rb") as source, partial_path.open("wb") as output:
|
||||
source.seek(offset)
|
||||
while remaining:
|
||||
chunk = source.read(min(MINIMAX_H3_HTTP_READ_CHUNK_BYTES, remaining))
|
||||
if not chunk:
|
||||
raise ValueError(
|
||||
f"tar material payload is truncated with {remaining} bytes left"
|
||||
)
|
||||
if len(chunk) > remaining:
|
||||
raise ValueError("tar material reader returned too many bytes")
|
||||
output.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
partial_path.replace(output_path)
|
||||
except Exception:
|
||||
partial_path.unlink(missing_ok=True)
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def _http_media_type(response: Any) -> str | None:
|
||||
headers = getattr(response, "headers", None)
|
||||
if headers is None:
|
||||
return None
|
||||
get_content_type = getattr(headers, "get_content_type", None)
|
||||
if callable(get_content_type):
|
||||
value = get_content_type()
|
||||
else:
|
||||
value = headers.get("Content-Type") or headers.get("content-type")
|
||||
if isinstance(value, str):
|
||||
value = value.split(";", 1)[0].strip()
|
||||
return str(value).lower() if value else None
|
||||
|
||||
|
||||
def _stream_http_material(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
timeout_s: float,
|
||||
) -> str:
|
||||
# Use the repository's legacy urllib behavior. Model-specific material
|
||||
# localization intentionally does not perform the shared public SSRF
|
||||
# policy or a cumulative request deadline.
|
||||
with urllib.request.urlopen(uri, timeout=timeout_s) as response:
|
||||
media_type = _http_media_type(response)
|
||||
suffix = (
|
||||
_safe_suffix(uri)
|
||||
or _SUFFIX_BY_MEDIA_TYPE.get(str(media_type or "").lower())
|
||||
or _DEFAULT_SUFFIX_BY_TYPE.get(condition_type, ".bin")
|
||||
)
|
||||
output_path = (
|
||||
Path(_material_workdir(batch))
|
||||
/ f"condition_{int(condition_index):04d}{suffix}"
|
||||
)
|
||||
partial_path = output_path.with_name(output_path.name + ".partial")
|
||||
total = 0
|
||||
try:
|
||||
with partial_path.open("wb") as output:
|
||||
while True:
|
||||
chunk = response.read(MINIMAX_H3_HTTP_READ_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
if not isinstance(chunk, bytes):
|
||||
raise TypeError(
|
||||
"HTTP material response.read() must return bytes, got "
|
||||
f"{type(chunk).__name__}"
|
||||
)
|
||||
total += len(chunk)
|
||||
output.write(chunk)
|
||||
if total == 0:
|
||||
raise ValueError(f"HTTP material body is empty: {uri}")
|
||||
partial_path.replace(output_path)
|
||||
except Exception:
|
||||
partial_path.unlink(missing_ok=True)
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def minimax_h3_localize_material_uri(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
timeout_s: float = 120.0,
|
||||
) -> str:
|
||||
"""Return a local path for a canonical condition URI.
|
||||
|
||||
Local paths and local ``file://`` URIs are validated and returned without
|
||||
copying. HTTP(S), base64/data and direct tar-member URIs are materialized
|
||||
once per request and cached for all MiniMax H3 consumers.
|
||||
"""
|
||||
|
||||
if not isinstance(uri, str) or not uri:
|
||||
raise ValueError("condition URI must be a non-empty string")
|
||||
parsed = None
|
||||
for special_scheme in ("data", "base64", "tar+offset", "tar+b64header"):
|
||||
if uri.startswith(special_scheme + ":"):
|
||||
scheme = special_scheme
|
||||
break
|
||||
else:
|
||||
parsed = urllib.parse.urlsplit(uri)
|
||||
scheme = parsed.scheme
|
||||
|
||||
if scheme == "file":
|
||||
assert parsed is not None
|
||||
if parsed.netloc not in {"", "localhost"}:
|
||||
raise ValueError(f"file URI host must be local, got {parsed.netloc!r}")
|
||||
output_path = _checked_material_file(
|
||||
Path(urllib.parse.unquote(parsed.path)),
|
||||
label="MiniMax H3 material source",
|
||||
)
|
||||
_validate_material_once(batch, uri, output_path, condition_type=condition_type)
|
||||
return output_path
|
||||
if not scheme:
|
||||
output_path = _checked_material_file(
|
||||
Path(uri).expanduser(),
|
||||
label="MiniMax H3 material source",
|
||||
)
|
||||
_validate_material_once(batch, uri, output_path, condition_type=condition_type)
|
||||
return output_path
|
||||
if scheme == "s3":
|
||||
raise NotImplementedError(
|
||||
"MiniMax H3 s3:// material URIs require a configured artifact resolver"
|
||||
)
|
||||
|
||||
cache = batch.extra.setdefault(MINIMAX_H3_MATERIAL_CACHE_EXTRA_KEY, {})
|
||||
cached = cache.get(uri)
|
||||
if isinstance(cached, str):
|
||||
cached_path = Path(cached)
|
||||
if cached_path.exists():
|
||||
output_path = _checked_material_file(
|
||||
cached_path,
|
||||
label="cached MiniMax H3 material",
|
||||
)
|
||||
_validate_material_once(
|
||||
batch, uri, output_path, condition_type=condition_type
|
||||
)
|
||||
return output_path
|
||||
cache.pop(uri, None)
|
||||
|
||||
if scheme in {"http", "https"}:
|
||||
output_path = _stream_http_material(
|
||||
batch,
|
||||
uri,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
try:
|
||||
_validate_material_once(
|
||||
batch, uri, output_path, condition_type=condition_type
|
||||
)
|
||||
except Exception:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
raise
|
||||
cache[uri] = output_path
|
||||
return output_path
|
||||
|
||||
if scheme in {"data", "base64"}:
|
||||
output_path = _stream_base64_material(
|
||||
batch,
|
||||
uri,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
)
|
||||
try:
|
||||
_validate_material_once(
|
||||
batch, uri, output_path, condition_type=condition_type
|
||||
)
|
||||
except Exception:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
raise
|
||||
cache[uri] = output_path
|
||||
return output_path
|
||||
|
||||
if scheme in {"tar+offset", "tar+b64header"}:
|
||||
output_path = _stream_tar_member_material(
|
||||
batch,
|
||||
uri,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
)
|
||||
try:
|
||||
_validate_material_once(
|
||||
batch, uri, output_path, condition_type=condition_type
|
||||
)
|
||||
except Exception:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
raise
|
||||
cache[uri] = output_path
|
||||
return output_path
|
||||
|
||||
raise NotImplementedError(
|
||||
f"MiniMax H3 material localization does not support URI scheme {scheme!r}"
|
||||
)
|
||||
|
||||
|
||||
def minimax_h3_probe_material(
|
||||
batch: Any,
|
||||
uri: str,
|
||||
*,
|
||||
condition_type: str,
|
||||
condition_index: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Localize and return cached display-geometry facts for one condition."""
|
||||
|
||||
path = minimax_h3_localize_material_uri(
|
||||
batch,
|
||||
uri,
|
||||
condition_type=condition_type,
|
||||
condition_index=condition_index,
|
||||
)
|
||||
key = (uri, condition_type)
|
||||
facts = batch.extra.get(MINIMAX_H3_MATERIAL_PROBE_EXTRA_KEY, {}).get(key)
|
||||
if not isinstance(facts, dict) or not facts:
|
||||
raise RuntimeError(
|
||||
"MiniMax H3 material localization completed without cached probe facts"
|
||||
)
|
||||
return {"local_path": path, **facts}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_MATERIAL_CACHE_EXTRA_KEY",
|
||||
"MINIMAX_H3_MATERIAL_PROBE_EXTRA_KEY",
|
||||
"MINIMAX_H3_TEMP_DIRS_EXTRA_KEY",
|
||||
"minimax_h3_cleanup_temp_dirs",
|
||||
"minimax_h3_localize_material_uri",
|
||||
"minimax_h3_probe_material",
|
||||
"minimax_h3_register_temp_dir",
|
||||
]
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 packed-sequence materialization from the validated workspace
|
||||
builder, covering fl2va and t2va layouts.
|
||||
|
||||
Layout: [text L | imgvid_cond C | audio A(=t*2ch) | video_target V | pad P].
|
||||
Builder rules:
|
||||
- block-derived position infos, update masks, token tags, and cu_seqlens
|
||||
- img_position_ids fp64 grid: text rows (row_idx,0,0); video/cond t counter
|
||||
continues text_len with temporal interp spans (frame_rescale 5/3 x
|
||||
frame_per_token (1,4,4,4,4)); each spatial sqrt_area axis uses evenly spaced
|
||||
coordinates excluding the right endpoint, then scales them by INTERP;
|
||||
audio channel-major blocks pinned to the w-grid extremes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.dits.minimax_h3 import (
|
||||
MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
)
|
||||
|
||||
_INTERP = 32
|
||||
_T_GROUP = 5
|
||||
_FRAME_PER_TOKEN = (1, 4, 4, 4, 4)
|
||||
_FRAME_RESCALE = 5.0 / 3.0
|
||||
_PATCH_H = 2
|
||||
_PATCH_W = 2
|
||||
|
||||
|
||||
def _keyframe_cond_frame_indices(
|
||||
*,
|
||||
include_keyframe_cond: bool,
|
||||
keyframe_frame_indices: list[int] | tuple[int, ...] | None,
|
||||
) -> list[int]:
|
||||
if not include_keyframe_cond:
|
||||
if keyframe_frame_indices is not None:
|
||||
raise ValueError(
|
||||
"keyframe_frame_indices must be omitted when keyframe cond is not included"
|
||||
)
|
||||
return []
|
||||
if keyframe_frame_indices is None:
|
||||
raise ValueError("strict fl2va packed layout requires keyframe_frame_indices")
|
||||
if any(
|
||||
isinstance(value, bool) or not isinstance(value, int)
|
||||
for value in keyframe_frame_indices
|
||||
):
|
||||
raise ValueError(
|
||||
"strict fl2va packed layout requires integer keyframe_frame_indices"
|
||||
)
|
||||
out = list(keyframe_frame_indices)
|
||||
if tuple(out) not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"strict fl2va packed layout requires keyframe_frame_indices in "
|
||||
f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got {out!r}"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_keyframe_frame_indices(
|
||||
frame_indices: Sequence[int],
|
||||
*,
|
||||
frame_count: int | None,
|
||||
) -> list[int]:
|
||||
if frame_indices and frame_count is None:
|
||||
raise ValueError(
|
||||
"frame_count is required when keyframe_frame_indices are provided"
|
||||
)
|
||||
if frame_count is None:
|
||||
return []
|
||||
if frame_count <= 0:
|
||||
raise ValueError("frame_count must be positive")
|
||||
seen: dict[int, int] = {}
|
||||
resolved: list[int] = []
|
||||
for block_index, semantic_index in enumerate(frame_indices):
|
||||
if semantic_index == -1:
|
||||
resolved_index = frame_count - 1
|
||||
elif 0 <= semantic_index < frame_count:
|
||||
resolved_index = semantic_index
|
||||
else:
|
||||
raise ValueError(
|
||||
f"keyframe frame index {semantic_index} must be -1 or in "
|
||||
f"[0, {frame_count})"
|
||||
)
|
||||
previous = seen.get(resolved_index)
|
||||
if previous is not None:
|
||||
raise ValueError(
|
||||
f"keyframe frame index at block {block_index} resolves to "
|
||||
f"{resolved_index}, already bound by block {previous}"
|
||||
)
|
||||
seen[resolved_index] = block_index
|
||||
resolved.append(resolved_index)
|
||||
return resolved
|
||||
|
||||
|
||||
def _temporal_position_span(temporal_length: int) -> float:
|
||||
"""Temporal position span for patch_t=1, in fp64.
|
||||
|
||||
NOTE: intentionally NOT merged with ``_video_t_span``. This variant sums
|
||||
via numpy (pairwise summation), matching the fl2va anchor computation,
|
||||
while ``_video_t_span`` sums sequentially, matching the ref2va
|
||||
t-origin accumulation. The two orders diverge in the last ulp
|
||||
from n=16 onward, so each path must keep its own summation order.
|
||||
"""
|
||||
spans = np.ones(int(temporal_length), dtype=np.float64) * _FRAME_RESCALE
|
||||
for token_index in range(_T_GROUP):
|
||||
spans[token_index::_T_GROUP] *= _FRAME_PER_TOKEN[token_index]
|
||||
return float(spans.sum())
|
||||
|
||||
|
||||
def minimax_h3_packed_sequence(
|
||||
*,
|
||||
text_len: int,
|
||||
latent_t: int,
|
||||
latent_h: int,
|
||||
latent_w: int,
|
||||
audio_t: int,
|
||||
audio_channel: int = 2,
|
||||
include_keyframe_cond: bool,
|
||||
keyframe_frame_indices: list[int] | tuple[int, ...] | None = None,
|
||||
frame_count: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the packed-sequence structural fields for one CFG branch.
|
||||
|
||||
The used length is padded up to a multiple of 64.
|
||||
"""
|
||||
ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W
|
||||
frame_rows = ph * pw
|
||||
cond_frame_indices = _keyframe_cond_frame_indices(
|
||||
include_keyframe_cond=include_keyframe_cond,
|
||||
keyframe_frame_indices=keyframe_frame_indices,
|
||||
)
|
||||
resolved_cond_frame_indices = _resolve_keyframe_frame_indices(
|
||||
cond_frame_indices,
|
||||
frame_count=frame_count,
|
||||
)
|
||||
cond_rows = len(cond_frame_indices) * frame_rows
|
||||
video_rows = latent_t * frame_rows
|
||||
audio_rows = audio_t * audio_channel
|
||||
used = text_len + cond_rows + audio_rows + video_rows
|
||||
seq_len = (
|
||||
(used + MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT - 1)
|
||||
// MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT
|
||||
* MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT
|
||||
)
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cond_sl = slice(text_len, text_len + cond_rows)
|
||||
audio_sl = slice(cond_sl.stop, cond_sl.stop + audio_rows)
|
||||
video_sl = slice(audio_sl.stop, audio_sl.stop + video_rows)
|
||||
target_img_pos = torch.arange(video_sl.start, video_sl.stop)
|
||||
img_pos = (
|
||||
torch.cat([torch.arange(cond_sl.start, cond_sl.stop), target_img_pos])
|
||||
if cond_rows
|
||||
else target_img_pos
|
||||
)
|
||||
update_mask = torch.zeros(img_pos.shape[0], dtype=torch.bool)
|
||||
update_mask[cond_rows:] = True
|
||||
audio_pos = torch.arange(audio_sl.start, audio_sl.stop)
|
||||
text_pos = torch.arange(0, text_len)
|
||||
|
||||
g = torch.zeros(seq_len, 3, dtype=torch.float64)
|
||||
g[text_sl, 0] = torch.arange(text_len, dtype=torch.float64)
|
||||
|
||||
t_grid = _video_t_grid(latent_t, float(text_len))
|
||||
sqrt_area = np.sqrt(latent_h * latent_w)
|
||||
h_grid = _axis_from_sqrt_area(latent_h, _PATCH_H, sqrt_area)
|
||||
w_grid = _axis_from_sqrt_area(latent_w, _PATCH_W, sqrt_area)
|
||||
hh, ww = torch.meshgrid(h_grid, w_grid, indexing="ij")
|
||||
frame = torch.stack([hh.reshape(-1), ww.reshape(-1)], dim=-1)
|
||||
video_g = g[video_sl].view(latent_t, frame_rows, 3)
|
||||
video_g[:, :, 0] = t_grid[:, None]
|
||||
video_g[:, :, 1:] = frame[None]
|
||||
for block_index, pixel_index in enumerate(resolved_cond_frame_indices):
|
||||
sl = slice(
|
||||
cond_sl.start + block_index * frame_rows,
|
||||
cond_sl.start + (block_index + 1) * frame_rows,
|
||||
)
|
||||
if pixel_index == 0:
|
||||
cond_t = float(text_len)
|
||||
elif frame_count is not None and pixel_index == frame_count - 1:
|
||||
cond_t = (
|
||||
float(text_len) + _temporal_position_span(latent_t) - _FRAME_RESCALE
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"fl2va packed layout only supports first/last keyframe anchors, "
|
||||
f"got resolved frame index {pixel_index}"
|
||||
)
|
||||
g[sl, 0] = cond_t
|
||||
g[sl, 1:] = frame
|
||||
audio_t_grid = float(text_len) + torch.arange(audio_t, dtype=torch.float64)
|
||||
g[audio_sl, 0] = audio_t_grid.repeat(audio_channel)
|
||||
g[audio_sl.start : audio_sl.start + audio_t, 2] = float(w_grid[0])
|
||||
g[audio_sl.start + audio_t : audio_sl.stop, 2] = float(w_grid[-1])
|
||||
|
||||
token_tags = torch.full((seq_len,), -1, dtype=torch.long) # PADDING
|
||||
token_tags[text_sl] = 1 # TEXT (fl2va image-segment override happens upstream)
|
||||
token_tags[audio_sl] = 2 # AUDIO
|
||||
token_tags[img_pos] = 0 # VIDEO
|
||||
|
||||
cu = torch.tensor([0, used, seq_len], dtype=torch.int32)
|
||||
return {
|
||||
"seq_len": seq_len,
|
||||
"img_pos": img_pos,
|
||||
"audio_pos": audio_pos,
|
||||
"text_pos": text_pos,
|
||||
"update_mask": update_mask,
|
||||
"img_position_ids": g,
|
||||
"token_tags": token_tags,
|
||||
"cu_seqlens": cu,
|
||||
}
|
||||
|
||||
|
||||
def _positive_int(
|
||||
block: Mapping[str, object],
|
||||
key: str,
|
||||
path: str,
|
||||
*,
|
||||
allow_zero: bool = False,
|
||||
) -> int:
|
||||
value = block.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{path}.{key} must be an integer")
|
||||
if value < 0 or (value == 0 and not allow_zero):
|
||||
predicate = "non-negative" if allow_zero else "positive"
|
||||
raise ValueError(f"{path}.{key} must be {predicate}")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _axis_from_sqrt_area(dim: int, patch: int, sqrt_area: float) -> torch.Tensor:
|
||||
ratio = dim / sqrt_area
|
||||
left = (1.0 - ratio) * 1.0 / 2.0
|
||||
right = left + ratio * 1.0
|
||||
grid = np.linspace(left, right, dim // patch, endpoint=False) * _INTERP
|
||||
return torch.from_numpy(grid).to(torch.float64)
|
||||
|
||||
|
||||
def _video_t_grid(n: int, origin: float) -> torch.Tensor:
|
||||
spans = torch.tensor(
|
||||
[_FRAME_RESCALE * _FRAME_PER_TOKEN[k % _T_GROUP] for k in range(n)],
|
||||
dtype=torch.float64,
|
||||
)
|
||||
return origin + torch.cat(
|
||||
[torch.zeros(1, dtype=torch.float64), spans[:-1].cumsum(0)]
|
||||
)
|
||||
|
||||
|
||||
def _video_t_span(n: int) -> float:
|
||||
# Sequential fp64 summation on purpose — see _temporal_position_span for
|
||||
# why the two span implementations must not be unified.
|
||||
return sum(_FRAME_RESCALE * _FRAME_PER_TOKEN[k % _T_GROUP] for k in range(n))
|
||||
|
||||
|
||||
def _range_for_slice(sl: slice) -> torch.Tensor:
|
||||
return torch.arange(sl.start, sl.stop, dtype=torch.long)
|
||||
|
||||
|
||||
def _cat_ranges(parts: list[torch.Tensor]) -> torch.Tensor:
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
if parts:
|
||||
return torch.cat(parts)
|
||||
return torch.empty(0, dtype=torch.long)
|
||||
|
||||
|
||||
def minimax_h3_packed_sequence_ref2va_blocks(
|
||||
*,
|
||||
text_len: int,
|
||||
latent_t: int,
|
||||
latent_h: int,
|
||||
latent_w: int,
|
||||
audio_t: int,
|
||||
ref_blocks: Sequence[Mapping[str, object]],
|
||||
audio_channel: int = 2,
|
||||
seq_len: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""General ref2va-family packed layout.
|
||||
|
||||
``ref_blocks`` are consumed in request/plan order:
|
||||
- ``{"kind": "image", "latent_h": H, "latent_w": W}``
|
||||
- ``{"kind": "audio", "ref_audio_t": T}``
|
||||
- ``{"kind": "video"|"video_audio", "ref_audio_t": T,
|
||||
"latent_t": RT, "latent_h": RH, "latent_w": RW}``
|
||||
|
||||
Video-bearing blocks pack their audio rows immediately before their video
|
||||
rows; both share the same temporal origin and advance by the longer of the
|
||||
audio and video spans. Standalone audio advances the target origin by its
|
||||
own T, and image blocks advance it by one integer slot.
|
||||
"""
|
||||
if not isinstance(ref_blocks, Sequence) or isinstance(ref_blocks, (str, bytes)):
|
||||
raise ValueError("ref_blocks must be a sequence")
|
||||
|
||||
parsed: list[dict[str, object]] = []
|
||||
ref_visual_rows = 0
|
||||
ref_audio_rows = 0
|
||||
for index, raw in enumerate(ref_blocks):
|
||||
path = f"ref_blocks[{index}]"
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError(f"{path} must be an object")
|
||||
kind = raw.get("kind", raw.get("type"))
|
||||
if not isinstance(kind, str) or not kind:
|
||||
raise ValueError(f"{path}.kind must be a non-empty string")
|
||||
if kind == "image":
|
||||
rh = _positive_int(raw, "latent_h", path)
|
||||
rw = _positive_int(raw, "latent_w", path)
|
||||
rows = (rh // _PATCH_H) * (rw // _PATCH_W)
|
||||
item = {"kind": kind, "latent_h": rh, "latent_w": rw, "rows": rows}
|
||||
ref_visual_rows += rows
|
||||
elif kind == "audio":
|
||||
rt = _positive_int(raw, "ref_audio_t", path, allow_zero=True)
|
||||
rows = rt * audio_channel
|
||||
item = {"kind": kind, "ref_audio_t": rt, "audio_rows": rows}
|
||||
ref_audio_rows += rows
|
||||
elif kind in ("video", "video_audio"):
|
||||
rt = _positive_int(raw, "ref_audio_t", path, allow_zero=True)
|
||||
vt = _positive_int(raw, "latent_t", path)
|
||||
vh = _positive_int(raw, "latent_h", path)
|
||||
vw = _positive_int(raw, "latent_w", path)
|
||||
frame_rows = (vh // _PATCH_H) * (vw // _PATCH_W)
|
||||
audio_rows = rt * audio_channel
|
||||
video_rows = vt * frame_rows
|
||||
item = {
|
||||
"kind": kind,
|
||||
"ref_audio_t": rt,
|
||||
"latent_t": vt,
|
||||
"latent_h": vh,
|
||||
"latent_w": vw,
|
||||
"frame_rows": frame_rows,
|
||||
"audio_rows": audio_rows,
|
||||
"video_rows": video_rows,
|
||||
}
|
||||
ref_audio_rows += audio_rows
|
||||
ref_visual_rows += video_rows
|
||||
else:
|
||||
raise ValueError(f"{path}.kind unsupported for ref2va: {kind!r}")
|
||||
parsed.append(item)
|
||||
|
||||
ph, pw = latent_h // _PATCH_H, latent_w // _PATCH_W
|
||||
frame_rows = ph * pw
|
||||
video_rows = latent_t * frame_rows
|
||||
audio_rows = audio_t * audio_channel
|
||||
ref_rows = ref_visual_rows + ref_audio_rows
|
||||
used = text_len + ref_rows + audio_rows + video_rows
|
||||
if seq_len is None:
|
||||
seq_len = (
|
||||
(used + MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT - 1)
|
||||
// MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT
|
||||
* MINIMAX_H3_PACKED_SEQUENCE_ALIGNMENT
|
||||
)
|
||||
if seq_len < used:
|
||||
raise ValueError(f"seq_len {seq_len} < used rows {used}")
|
||||
|
||||
text_sl = slice(0, text_len)
|
||||
cursor = text_len
|
||||
block_slices: list[dict[str, object]] = []
|
||||
for item in parsed:
|
||||
kind = str(item["kind"])
|
||||
if kind == "image":
|
||||
rows = int(item["rows"])
|
||||
visual_sl = slice(cursor, cursor + rows)
|
||||
cursor = visual_sl.stop
|
||||
block_slices.append({**item, "visual_sl": visual_sl})
|
||||
elif kind == "audio":
|
||||
rows = int(item["audio_rows"])
|
||||
audio_sl = slice(cursor, cursor + rows)
|
||||
cursor = audio_sl.stop
|
||||
block_slices.append({**item, "audio_sl": audio_sl})
|
||||
else:
|
||||
a_rows = int(item["audio_rows"])
|
||||
v_rows = int(item["video_rows"])
|
||||
audio_sl = slice(cursor, cursor + a_rows)
|
||||
visual_sl = slice(audio_sl.stop, audio_sl.stop + v_rows)
|
||||
cursor = visual_sl.stop
|
||||
block_slices.append({**item, "audio_sl": audio_sl, "visual_sl": visual_sl})
|
||||
|
||||
audio_sl = slice(cursor, cursor + audio_rows)
|
||||
video_sl = slice(audio_sl.stop, audio_sl.stop + video_rows)
|
||||
ref_img_pos_parts: list[torch.Tensor] = []
|
||||
ref_audio_pos_parts: list[torch.Tensor] = []
|
||||
g = torch.zeros(seq_len, 3, dtype=torch.float64)
|
||||
g[text_sl, 0] = torch.arange(text_len, dtype=torch.float64)
|
||||
|
||||
target_area = np.sqrt(latent_h * latent_w)
|
||||
h_grid = _axis_from_sqrt_area(latent_h, _PATCH_H, target_area)
|
||||
w_grid = _axis_from_sqrt_area(latent_w, _PATCH_W, target_area)
|
||||
hh, ww = torch.meshgrid(h_grid, w_grid, indexing="ij")
|
||||
target_frame = torch.stack([hh.reshape(-1), ww.reshape(-1)], dim=-1)
|
||||
|
||||
t_cursor = float(text_len)
|
||||
for item in block_slices:
|
||||
kind = str(item["kind"])
|
||||
if kind == "image":
|
||||
visual_sl = item["visual_sl"]
|
||||
assert isinstance(visual_sl, slice)
|
||||
ref_img_pos_parts.append(_range_for_slice(visual_sl))
|
||||
rh = int(item["latent_h"])
|
||||
rw = int(item["latent_w"])
|
||||
area = np.sqrt(rh * rw)
|
||||
ref_hh, ref_ww = torch.meshgrid(
|
||||
_axis_from_sqrt_area(rh, _PATCH_H, area),
|
||||
_axis_from_sqrt_area(rw, _PATCH_W, area),
|
||||
indexing="ij",
|
||||
)
|
||||
g[visual_sl, 0] = t_cursor
|
||||
g[visual_sl, 1] = ref_hh.reshape(-1)
|
||||
g[visual_sl, 2] = ref_ww.reshape(-1)
|
||||
t_cursor += 1.0
|
||||
elif kind == "audio":
|
||||
audio_ref_sl = item["audio_sl"]
|
||||
assert isinstance(audio_ref_sl, slice)
|
||||
ref_t = int(item["ref_audio_t"])
|
||||
ref_audio_pos_parts.append(_range_for_slice(audio_ref_sl))
|
||||
ref_t_grid = t_cursor + torch.arange(ref_t, dtype=torch.float64)
|
||||
g[audio_ref_sl, 0] = ref_t_grid.repeat(audio_channel)
|
||||
if ref_t:
|
||||
g[audio_ref_sl.start : audio_ref_sl.start + ref_t, 2] = float(w_grid[0])
|
||||
g[audio_ref_sl.start + ref_t : audio_ref_sl.stop, 2] = float(w_grid[-1])
|
||||
t_cursor += float(ref_t)
|
||||
else:
|
||||
audio_ref_sl = item["audio_sl"]
|
||||
visual_sl = item["visual_sl"]
|
||||
assert isinstance(audio_ref_sl, slice)
|
||||
assert isinstance(visual_sl, slice)
|
||||
ref_t = int(item["ref_audio_t"])
|
||||
vt = int(item["latent_t"])
|
||||
vh = int(item["latent_h"])
|
||||
vw = int(item["latent_w"])
|
||||
ref_audio_pos_parts.append(_range_for_slice(audio_ref_sl))
|
||||
ref_img_pos_parts.append(_range_for_slice(visual_sl))
|
||||
|
||||
ref_area = np.sqrt(vh * vw)
|
||||
rv_h_grid = _axis_from_sqrt_area(vh, _PATCH_H, ref_area)
|
||||
rv_w_grid = _axis_from_sqrt_area(vw, _PATCH_W, ref_area)
|
||||
rv_hh, rv_ww = torch.meshgrid(rv_h_grid, rv_w_grid, indexing="ij")
|
||||
|
||||
ref_t_grid = t_cursor + torch.arange(ref_t, dtype=torch.float64)
|
||||
g[audio_ref_sl, 0] = ref_t_grid.repeat(audio_channel)
|
||||
if ref_t:
|
||||
g[audio_ref_sl.start : audio_ref_sl.start + ref_t, 2] = float(
|
||||
rv_w_grid[0]
|
||||
)
|
||||
g[audio_ref_sl.start + ref_t : audio_ref_sl.stop, 2] = float(
|
||||
rv_w_grid[-1]
|
||||
)
|
||||
|
||||
rv_frame = torch.stack([rv_hh.reshape(-1), rv_ww.reshape(-1)], dim=-1)
|
||||
rv_g = g[visual_sl].view(vt, int(item["frame_rows"]), 3)
|
||||
rv_g[:, :, 0] = _video_t_grid(vt, t_cursor)[:, None]
|
||||
rv_g[:, :, 1:] = rv_frame[None]
|
||||
t_cursor += max(float(ref_t), _video_t_span(vt))
|
||||
|
||||
audio_t_grid = t_cursor + torch.arange(audio_t, dtype=torch.float64)
|
||||
g[audio_sl, 0] = audio_t_grid.repeat(audio_channel)
|
||||
g[audio_sl.start : audio_sl.start + audio_t, 2] = float(w_grid[0])
|
||||
g[audio_sl.start + audio_t : audio_sl.stop, 2] = float(w_grid[-1])
|
||||
|
||||
video_g = g[video_sl].view(latent_t, frame_rows, 3)
|
||||
video_g[:, :, 0] = _video_t_grid(latent_t, t_cursor)[:, None]
|
||||
video_g[:, :, 1:] = target_frame[None]
|
||||
|
||||
target_img_pos = _range_for_slice(video_sl)
|
||||
target_audio_pos = _range_for_slice(audio_sl)
|
||||
img_pos = _cat_ranges(ref_img_pos_parts + [target_img_pos])
|
||||
audio_pos = _cat_ranges(ref_audio_pos_parts + [target_audio_pos])
|
||||
|
||||
update_mask = torch.zeros(img_pos.shape[0], dtype=torch.bool)
|
||||
update_mask[ref_visual_rows:] = True
|
||||
audio_update_mask = torch.zeros(audio_pos.shape[0], dtype=torch.bool)
|
||||
audio_update_mask[ref_audio_rows:] = True
|
||||
text_pos = torch.arange(0, text_len)
|
||||
|
||||
token_tags = torch.full((seq_len,), -1, dtype=torch.long) # PADDING
|
||||
token_tags[text_sl] = 1 # TEXT
|
||||
token_tags[audio_pos] = 2 # AUDIO (refs + target)
|
||||
token_tags[img_pos] = 0 # VIDEO (refs + target)
|
||||
|
||||
cu = torch.tensor([0, used, seq_len], dtype=torch.int32)
|
||||
return {
|
||||
"seq_len": seq_len,
|
||||
"img_pos": img_pos,
|
||||
"audio_pos": audio_pos,
|
||||
"text_pos": text_pos,
|
||||
"update_mask": update_mask,
|
||||
"audio_update_mask": audio_update_mask,
|
||||
"img_position_ids": g,
|
||||
"token_tags": token_tags,
|
||||
"cu_seqlens": cu,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_packed_sequence",
|
||||
"minimax_h3_packed_sequence_ref2va_blocks",
|
||||
]
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def _int_tuple(value: Sequence[int], name: str, length: int) -> tuple[int, ...]:
|
||||
if len(value) != length:
|
||||
raise ValueError(f"{name} must have length {length}, got {list(value)!r}")
|
||||
out = tuple(int(item) for item in value)
|
||||
if any(item <= 0 for item in out):
|
||||
raise ValueError(f"{name} values must be positive, got {list(value)!r}")
|
||||
return out
|
||||
|
||||
|
||||
def _rank(tensor: torch.Tensor, name: str, rank: int) -> None:
|
||||
if tensor.ndim != rank:
|
||||
raise ValueError(f"{name} must be rank {rank}, got shape={list(tensor.shape)}")
|
||||
|
||||
|
||||
def minimax_h3_patchify_video_latent(
|
||||
latent: torch.Tensor,
|
||||
*,
|
||||
patch_size: Sequence[int],
|
||||
) -> torch.Tensor:
|
||||
"""Pack SGLang video latent [B,C,T,H,W] into DiT token rows."""
|
||||
|
||||
_rank(latent, "video latent", 5)
|
||||
pt, ph, pw = _int_tuple(patch_size, "patch_size", 3)
|
||||
batch, channel, full_t, full_h, full_w = (int(dim) for dim in latent.shape)
|
||||
if full_t % pt or full_h % ph or full_w % pw:
|
||||
raise ValueError(
|
||||
"video latent spatial/time dims must be divisible by patch_size: "
|
||||
f"shape={list(latent.shape)}, patch_size={[pt, ph, pw]}"
|
||||
)
|
||||
t, h, w = full_t // pt, full_h // ph, full_w // pw
|
||||
packed = latent.reshape(batch, channel, t, pt, h, ph, w, pw)
|
||||
packed = torch.einsum("nctrhpwq->nthwcrpq", packed)
|
||||
return packed.reshape(batch * t * h * w, channel * pt * ph * pw).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_unpatchify_video_tokens(
|
||||
rows: torch.Tensor,
|
||||
*,
|
||||
latent_shape: Sequence[int],
|
||||
patch_size: Sequence[int],
|
||||
) -> torch.Tensor:
|
||||
"""Unpack DiT video token rows into SGLang latent [B,C,T,H,W]."""
|
||||
|
||||
_rank(rows, "video token rows", 2)
|
||||
t, h, w, channel = _int_tuple(latent_shape, "latent_shape", 4)
|
||||
pt, ph, pw = _int_tuple(patch_size, "patch_size", 3)
|
||||
expected_dim = pt * ph * pw * channel
|
||||
if int(rows.shape[-1]) != expected_dim:
|
||||
raise ValueError(
|
||||
f"video token dim {int(rows.shape[-1])} != patch volume * channel "
|
||||
f"{expected_dim} for latent_shape={list(latent_shape)}, "
|
||||
f"patch_size={[pt, ph, pw]}"
|
||||
)
|
||||
rows_per_sample = t * h * w
|
||||
if int(rows.shape[0]) % rows_per_sample:
|
||||
raise ValueError(
|
||||
f"video rows {int(rows.shape[0])} must be divisible by t*h*w "
|
||||
f"{rows_per_sample} for latent_shape={list(latent_shape)}"
|
||||
)
|
||||
packed = rows.reshape(-1, t, h, w, channel, pt, ph, pw)
|
||||
latent = torch.einsum("nthwcrpq->nctrhpwq", packed)
|
||||
return latent.reshape(-1, channel, t * pt, h * ph, w * pw).contiguous()
|
||||
|
||||
|
||||
def minimax_h3_unpack_audio_tokens(
|
||||
rows: torch.Tensor,
|
||||
*,
|
||||
audio_t: int,
|
||||
audio_channel: int,
|
||||
) -> torch.Tensor:
|
||||
"""Unpack DiT audio token rows into SGLang audio VAE latent [C,latent_dim,T]."""
|
||||
|
||||
_rank(rows, "audio token rows", 2)
|
||||
audio_t = int(audio_t)
|
||||
audio_channel = int(audio_channel)
|
||||
if audio_t <= 0 or audio_channel <= 0:
|
||||
raise ValueError(
|
||||
f"audio_t and audio_channel must be positive, got {audio_t=} "
|
||||
f"{audio_channel=}"
|
||||
)
|
||||
if int(rows.shape[0]) != audio_t:
|
||||
raise ValueError(f"audio rows {int(rows.shape[0])} != audio_t {audio_t}")
|
||||
if audio_t % audio_channel:
|
||||
raise ValueError(
|
||||
f"audio_t must be divisible by audio_channel, got {audio_t=} "
|
||||
f"{audio_channel=}"
|
||||
)
|
||||
native = rows.reshape(audio_channel, audio_t // audio_channel, int(rows.shape[-1]))
|
||||
return native.permute(0, 2, 1).contiguous()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_patchify_video_latent",
|
||||
"minimax_h3_unpack_audio_tokens",
|
||||
"minimax_h3_unpatchify_video_tokens",
|
||||
]
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 probe -> resolve-once admission hook.
|
||||
|
||||
This module is intentionally data/CPU only. It localizes condition media,
|
||||
caches display-geometry facts, freezes every target/material canvas, and
|
||||
resolves the real aligned workload before a video job is published or sent to
|
||||
the scheduler.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_MAX_DURATION_SECONDS,
|
||||
MINIMAX_H3_MIN_DURATION_SECONDS,
|
||||
MINIMAX_H3_SUPPORTED_FPS,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
MINIMAX_H3_TEMP_DIRS_EXTRA_KEY,
|
||||
minimax_h3_cleanup_temp_dirs,
|
||||
minimax_h3_probe_material,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
MINIMAX_H3_BASE_SHORT_EDGE,
|
||||
MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY,
|
||||
MiniMaxH3ResolvedPlan,
|
||||
minimax_h3_plan_from_batch,
|
||||
minimax_h3_resolve_spatial_shape,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.time_request import (
|
||||
minimax_h3_align_frame_count,
|
||||
minimax_h3_audio_latent_t,
|
||||
minimax_h3_video_latent_t,
|
||||
)
|
||||
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY = "minimax_h3_probe_facts_by_condition"
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY = "minimax_h3_resolved_material_shapes"
|
||||
|
||||
|
||||
def _replace_plan_shape(
|
||||
plan: MiniMaxH3ResolvedPlan, shape: dict[str, Any]
|
||||
) -> MiniMaxH3ResolvedPlan:
|
||||
return MiniMaxH3ResolvedPlan(
|
||||
task=plan.task,
|
||||
prompt=plan.prompt,
|
||||
seed=plan.seed,
|
||||
materials=plan.materials,
|
||||
encoders=plan.encoders,
|
||||
branches=plan.branches,
|
||||
default_flow_shift=plan.default_flow_shift,
|
||||
default_audio_flow_shift=plan.default_audio_flow_shift,
|
||||
flow_shift=plan.flow_shift,
|
||||
audio_flow_shift=plan.audio_flow_shift,
|
||||
shape=shape,
|
||||
condition_mask=plan.condition_mask,
|
||||
)
|
||||
|
||||
|
||||
def _display_shape(facts: dict[str, Any], *, label: str) -> tuple[float, float]:
|
||||
try:
|
||||
width = float(facts["display_width"])
|
||||
height = float(facts["display_height"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} has no usable display geometry") from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"{label} has no usable display geometry")
|
||||
return width, height
|
||||
|
||||
|
||||
def _resolve_deferred_spatial_shape(
|
||||
plan: MiniMaxH3ResolvedPlan,
|
||||
shape: dict[str, Any],
|
||||
probe_facts: dict[int, dict[str, Any]],
|
||||
) -> None:
|
||||
if str(shape.get("geometry")) != "deferred":
|
||||
return
|
||||
if plan.task == "fl2va":
|
||||
candidates = [
|
||||
material
|
||||
for material in plan.materials
|
||||
if material.material_chain == "image.target_canvas"
|
||||
]
|
||||
if len(candidates) not in {1, 2}:
|
||||
raise ValueError(
|
||||
f"fl2va requires one or two keyframe materials, got {len(candidates)}"
|
||||
)
|
||||
for material in candidates:
|
||||
if material.frame_index not in {0, -1}:
|
||||
raise ValueError(
|
||||
"fl2va deferred geometry requires semantic frame_index 0 or "
|
||||
f"-1, got {material.frame_index!r} for "
|
||||
f"conditions[{material.condition_index}]"
|
||||
)
|
||||
# Select by semantic time, not request/material iteration order. The
|
||||
# last-frame sentinel sorts after the first-frame anchor.
|
||||
source = min(
|
||||
candidates,
|
||||
key=lambda material: (
|
||||
material.frame_index == -1,
|
||||
int(material.frame_index),
|
||||
int(material.condition_index),
|
||||
),
|
||||
)
|
||||
geometry_source = (
|
||||
"first_keyframe" if source.frame_index == 0 else "last_keyframe"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"task {plan.task!r} has unsupported deferred target geometry")
|
||||
width, height = _display_shape(
|
||||
probe_facts[int(source.condition_index)],
|
||||
label=f"conditions[{source.condition_index}]",
|
||||
)
|
||||
shape.update(
|
||||
minimax_h3_resolve_spatial_shape(
|
||||
width=width,
|
||||
height=height,
|
||||
base_short_edge=int(shape["base_short_edge"]),
|
||||
)
|
||||
)
|
||||
shape["geometry_source"] = geometry_source
|
||||
shape["geometry_source_condition_index"] = int(source.condition_index)
|
||||
if source.frame_index is not None:
|
||||
shape["geometry_source_frame_index"] = int(source.frame_index)
|
||||
|
||||
|
||||
def _resolve_deferred_temporal_shape(
|
||||
plan: MiniMaxH3ResolvedPlan,
|
||||
shape: dict[str, Any],
|
||||
probe_facts: dict[int, dict[str, Any]],
|
||||
) -> None:
|
||||
if str(shape.get("temporal")) != "deferred_from_audio_reference":
|
||||
return
|
||||
sources = [
|
||||
material
|
||||
for material in plan.materials
|
||||
if material.condition_type in {"audio", "video", "video_audio"}
|
||||
and bool(probe_facts[int(material.condition_index)].get("has_audio"))
|
||||
]
|
||||
if len(sources) != 1:
|
||||
raise ValueError(
|
||||
"audio-derived target duration requires exactly one probed "
|
||||
f"condition with an audio stream, got {len(sources)}"
|
||||
)
|
||||
source = sources[0]
|
||||
facts = probe_facts[int(source.condition_index)]
|
||||
try:
|
||||
duration_seconds = float(facts["audio_duration_seconds"]) - float(
|
||||
source.start_time_seconds
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError("audio reference has no positive probed duration") from exc
|
||||
if duration_seconds <= 0:
|
||||
raise ValueError("audio reference has no positive probed duration")
|
||||
if not (
|
||||
MINIMAX_H3_MIN_DURATION_SECONDS
|
||||
<= duration_seconds
|
||||
<= MINIMAX_H3_MAX_DURATION_SECONDS
|
||||
):
|
||||
raise ValueError(
|
||||
"audio reference duration must be in "
|
||||
f"[{MINIMAX_H3_MIN_DURATION_SECONDS:g}, "
|
||||
f"{MINIMAX_H3_MAX_DURATION_SECONDS:g}] seconds, got {duration_seconds:g}"
|
||||
)
|
||||
fps = MINIMAX_H3_SUPPORTED_FPS
|
||||
frame_count = minimax_h3_align_frame_count(int(round(duration_seconds * fps)))
|
||||
aligned_duration = frame_count / fps
|
||||
shape.update(
|
||||
{
|
||||
"temporal": "resolved_from_audio_reference",
|
||||
"duration_seconds": aligned_duration,
|
||||
"frame_count": frame_count,
|
||||
"video_latent_t": minimax_h3_video_latent_t(frame_count),
|
||||
"audio_latent_t": minimax_h3_audio_latent_t(aligned_duration),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_reference_start_times(
|
||||
plan: MiniMaxH3ResolvedPlan,
|
||||
probe_facts: dict[int, dict[str, Any]],
|
||||
) -> None:
|
||||
for material in plan.materials:
|
||||
start_time_seconds = float(material.start_time_seconds)
|
||||
if start_time_seconds == 0:
|
||||
continue
|
||||
condition_index = int(material.condition_index)
|
||||
facts = probe_facts[condition_index]
|
||||
try:
|
||||
video_duration_seconds = float(facts["video_duration_seconds"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"conditions[{condition_index}].start_time_seconds requires "
|
||||
"a video with a positive probed duration"
|
||||
) from exc
|
||||
if start_time_seconds >= video_duration_seconds:
|
||||
raise ValueError(
|
||||
f"conditions[{condition_index}].start_time_seconds must be less "
|
||||
f"than the video duration {video_duration_seconds:g}, got "
|
||||
f"{start_time_seconds:g}"
|
||||
)
|
||||
if bool(facts.get("has_audio")):
|
||||
try:
|
||||
audio_duration_seconds = float(facts["audio_duration_seconds"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"conditions[{condition_index}] has no usable audio duration"
|
||||
) from exc
|
||||
if start_time_seconds >= audio_duration_seconds:
|
||||
raise ValueError(
|
||||
f"conditions[{condition_index}].start_time_seconds must be "
|
||||
f"less than the soundtrack duration {audio_duration_seconds:g}, "
|
||||
f"got {start_time_seconds:g}"
|
||||
)
|
||||
|
||||
|
||||
def _resolved_work_frame_count(
|
||||
plan: MiniMaxH3ResolvedPlan,
|
||||
shape: dict[str, Any],
|
||||
probe_facts: dict[int, dict[str, Any]],
|
||||
) -> int:
|
||||
if shape.get("frame_count") is not None:
|
||||
return int(shape["frame_count"])
|
||||
raise ValueError("MiniMax H3 target frame count remained unresolved before queue")
|
||||
|
||||
|
||||
def _preserve_prequeue_material_dirs(batch: Any) -> None:
|
||||
temp_registry = batch.extra.get(MINIMAX_H3_TEMP_DIRS_EXTRA_KEY)
|
||||
if isinstance(temp_registry, dict) and "material" in temp_registry:
|
||||
# Multi-output dispatch shallow-copies request extras. Keep the
|
||||
# single localized source closure owned by the API request until
|
||||
# every output finishes; encoder-stage "material" cleanup must not
|
||||
# delete it after the first expanded output.
|
||||
paths = temp_registry.pop("material")
|
||||
prequeue_paths = temp_registry.setdefault("prequeue_material", [])
|
||||
for path in paths if isinstance(paths, list) else ():
|
||||
if path not in prequeue_paths:
|
||||
prequeue_paths.append(path)
|
||||
|
||||
|
||||
def minimax_h3_prepare_for_queue(batch: Any) -> MiniMaxH3ResolvedPlan:
|
||||
"""Freeze MiniMax H3 media/shape facts before queue admission."""
|
||||
|
||||
try:
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
raise ValueError(
|
||||
"MiniMax H3 pre-queue validation requires a canonical request"
|
||||
)
|
||||
|
||||
probe_facts: dict[int, dict[str, Any]] = {}
|
||||
for material in plan.materials:
|
||||
probe_facts[int(material.condition_index)] = minimax_h3_probe_material(
|
||||
batch,
|
||||
material.uri,
|
||||
condition_type=material.condition_type,
|
||||
condition_index=int(material.condition_index),
|
||||
)
|
||||
batch.extra[MINIMAX_H3_PROBE_FACTS_EXTRA_KEY] = probe_facts
|
||||
|
||||
shape = dict(plan.shape)
|
||||
_validate_reference_start_times(plan, probe_facts)
|
||||
_resolve_deferred_spatial_shape(plan, shape, probe_facts)
|
||||
_resolve_deferred_temporal_shape(plan, shape, probe_facts)
|
||||
if str(shape.get("geometry")) != "resolved_v2":
|
||||
raise ValueError(
|
||||
"MiniMax H3 target geometry remained unresolved before queue"
|
||||
)
|
||||
|
||||
material_shapes: dict[int, dict[str, Any]] = {}
|
||||
for material in plan.materials:
|
||||
condition_index = int(material.condition_index)
|
||||
if material.material_chain == "image.target_canvas":
|
||||
resolved = {
|
||||
key: shape[key]
|
||||
for key in (
|
||||
"geometry",
|
||||
"shape_policy_version",
|
||||
"base_short_edge",
|
||||
"effective_short_edge",
|
||||
"size_mode",
|
||||
"max_pixels",
|
||||
"multiple",
|
||||
"rounding",
|
||||
"width",
|
||||
"height",
|
||||
)
|
||||
if key in shape
|
||||
}
|
||||
elif material.material_chain in {
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
}:
|
||||
width, height = _display_shape(
|
||||
probe_facts[condition_index],
|
||||
label=f"conditions[{condition_index}]",
|
||||
)
|
||||
resolved = minimax_h3_resolve_spatial_shape(
|
||||
width=width,
|
||||
height=height,
|
||||
base_short_edge=MINIMAX_H3_BASE_SHORT_EDGE,
|
||||
)
|
||||
elif material.material_chain == "image.reference_preserve":
|
||||
width, height = _display_shape(
|
||||
probe_facts[condition_index],
|
||||
label=f"conditions[{condition_index}]",
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.reference_encoding import (
|
||||
minimax_h3_resolve_reference_image_shape,
|
||||
)
|
||||
|
||||
resolved = minimax_h3_resolve_reference_image_shape(
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
else:
|
||||
continue
|
||||
resolved = dict(resolved)
|
||||
resolved["condition_index"] = condition_index
|
||||
material_shapes[condition_index] = resolved
|
||||
batch.extra[MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY] = material_shapes
|
||||
|
||||
work_frames = _resolved_work_frame_count(plan, shape, probe_facts)
|
||||
resolved_plan = _replace_plan_shape(plan, shape)
|
||||
batch.extra[MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY] = resolved_plan
|
||||
# Req delegates these fields to SamplingParams. Freezing them here makes
|
||||
# queue metadata and dynamic-batch signatures use the same final shape
|
||||
# that the MiniMax H3 stages consume.
|
||||
batch.width = int(shape["width"])
|
||||
batch.height = int(shape["height"])
|
||||
batch.fps = MINIMAX_H3_SUPPORTED_FPS
|
||||
batch.num_frames = int(work_frames)
|
||||
_preserve_prequeue_material_dirs(batch)
|
||||
return resolved_plan
|
||||
except Exception:
|
||||
minimax_h3_cleanup_temp_dirs(batch)
|
||||
batch.extra.pop(MINIMAX_H3_PROBE_FACTS_EXTRA_KEY, None)
|
||||
batch.extra.pop(MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY, None)
|
||||
raise
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_PROBE_FACTS_EXTRA_KEY",
|
||||
"MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY",
|
||||
"minimax_h3_prepare_for_queue",
|
||||
]
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 Qwen presentation building.
|
||||
|
||||
Builds the positive presentation token stream:
|
||||
- fl2va: '<Picture 1>: ' label + vision block (<|vision_start|> +
|
||||
N*<|image_pad|> + <|vision_end|>) + prompt text.
|
||||
- t2va: prompt text only (no vision block).
|
||||
Prompt text passes through verbatim (no stripping or rewriting).
|
||||
|
||||
All presentation variants are emitted through the shared ``_Presentation``
|
||||
accumulator so ids and AdaLN token tags cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
VISION_START = "<|vision_start|>"
|
||||
VISION_END = "<|vision_end|>"
|
||||
IMAGE_PAD = "<|image_pad|>"
|
||||
VIDEO_PAD = "<|video_pad|>"
|
||||
|
||||
_TEXT_TAG = 1
|
||||
_VIDEO_TAG = 0
|
||||
|
||||
|
||||
def _text_ids(tokenizer: Any, text: str) -> list[int]:
|
||||
return list(tokenizer(text, add_special_tokens=False)["input_ids"])
|
||||
|
||||
|
||||
def _vision_block_ids(tokenizer: Any, pad_token: str, count: int) -> list[int]:
|
||||
return (
|
||||
[tokenizer.convert_tokens_to_ids(VISION_START)]
|
||||
+ [tokenizer.convert_tokens_to_ids(pad_token)] * int(count)
|
||||
+ [tokenizer.convert_tokens_to_ids(VISION_END)]
|
||||
)
|
||||
|
||||
|
||||
class _Presentation:
|
||||
"""Accumulates aligned (ids, token_tags) presentation segments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ids: list[int] = []
|
||||
self.tags: list[int] = []
|
||||
|
||||
def text(self, token_ids: list[int]) -> None:
|
||||
self.ids += token_ids
|
||||
self.tags += [_TEXT_TAG] * len(token_ids)
|
||||
|
||||
def vision(self, token_ids: list[int]) -> None:
|
||||
self.ids += token_ids
|
||||
self.tags += [_VIDEO_TAG] * len(token_ids)
|
||||
|
||||
def build(self) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
return (
|
||||
torch.tensor(self.ids, dtype=torch.long),
|
||||
torch.tensor(self.tags, dtype=torch.long),
|
||||
)
|
||||
|
||||
|
||||
def _timestamped_video_blocks(
|
||||
presentation: _Presentation,
|
||||
tokenizer: Any,
|
||||
*,
|
||||
counts: Sequence[int],
|
||||
timestamps: Sequence[float],
|
||||
context: str,
|
||||
) -> None:
|
||||
"""Emit per-temporal-block ``<{t:.1f} seconds>`` text + VIDEO vision."""
|
||||
|
||||
counts = [int(value) for value in counts]
|
||||
timestamps = [float(value) for value in timestamps]
|
||||
if not counts or len(counts) != len(timestamps):
|
||||
raise ValueError(f"{context}video block token counts and timestamps must align")
|
||||
for count, timestamp in zip(counts, timestamps):
|
||||
if count <= 0:
|
||||
raise ValueError(f"{context}video block token count must be positive")
|
||||
presentation.text(_text_ids(tokenizer, f"<{timestamp:.1f} seconds>"))
|
||||
presentation.vision(_vision_block_ids(tokenizer, VIDEO_PAD, count))
|
||||
|
||||
|
||||
def minimax_h3_text_only_ids(tokenizer: Any, prompt: str) -> torch.Tensor:
|
||||
"""t2va presentation: verbatim prompt, no special tokens."""
|
||||
if not prompt:
|
||||
raise ValueError("prompt must be non-empty")
|
||||
return torch.tensor(_text_ids(tokenizer, prompt), dtype=torch.long)
|
||||
|
||||
|
||||
def minimax_h3_multi_image_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
image_token_counts: list[int],
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
if not image_token_counts:
|
||||
raise ValueError("image_token_counts must be non-empty")
|
||||
presentation = _Presentation()
|
||||
for index, count in enumerate(image_token_counts, start=1):
|
||||
if int(count) <= 0:
|
||||
raise ValueError("image_token_count must be positive")
|
||||
presentation.text(_text_ids(tokenizer, f"<Picture {index}>: "))
|
||||
presentation.vision(_vision_block_ids(tokenizer, IMAGE_PAD, count))
|
||||
presentation.text(_text_ids(tokenizer, prompt))
|
||||
return presentation.build()
|
||||
|
||||
|
||||
def minimax_h3_ref2va_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
condition_labels: list[tuple[str, int]],
|
||||
image_token_count: int | list[int] | None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""ref2va positive presentation:
|
||||
|
||||
per condition in request order — image i: ``<Picture i>: `` label followed
|
||||
by the vision block; audio j: ``<Audio j>: `` label only (audio content
|
||||
never enters Qwen) — then the verbatim prompt. Returns (ids, token_tags)
|
||||
with the vision block tagged VIDEO(0) and everything else TEXT(1).
|
||||
|
||||
condition_labels: [("image", 1), ("audio", 1), ...] with 1-based ordinals
|
||||
per type.
|
||||
"""
|
||||
return minimax_h3_ref2va_video_presentation(
|
||||
tokenizer,
|
||||
prompt=prompt,
|
||||
condition_labels=condition_labels,
|
||||
image_token_count=image_token_count,
|
||||
video_block_token_counts=None,
|
||||
video_block_timestamps=None,
|
||||
)
|
||||
|
||||
|
||||
def _as_int_list(value: int | Sequence[int] | None, *, name: str) -> list[int]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, int):
|
||||
return [int(value)]
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an int or a sequence of ints")
|
||||
return [int(item) for item in value]
|
||||
|
||||
|
||||
def _as_nested_int_list(
|
||||
value: Sequence[int] | Sequence[Sequence[int]] | None,
|
||||
*,
|
||||
name: str,
|
||||
) -> list[list[int]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be a sequence")
|
||||
if len(value) == 0:
|
||||
return []
|
||||
first = value[0]
|
||||
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
|
||||
out: list[list[int]] = []
|
||||
for group in value:
|
||||
if not isinstance(group, Sequence) or isinstance(group, (str, bytes)):
|
||||
raise ValueError(f"{name} must not mix nested and flat entries")
|
||||
out.append([int(item) for item in group])
|
||||
return out
|
||||
return [[int(item) for item in value]]
|
||||
|
||||
|
||||
def _as_nested_float_list(
|
||||
value: Sequence[float] | Sequence[Sequence[float]] | None,
|
||||
*,
|
||||
name: str,
|
||||
) -> list[list[float]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise ValueError(f"{name} must be a sequence")
|
||||
if len(value) == 0:
|
||||
return []
|
||||
first = value[0]
|
||||
if isinstance(first, Sequence) and not isinstance(first, (str, bytes)):
|
||||
out: list[list[float]] = []
|
||||
for group in value:
|
||||
if not isinstance(group, Sequence) or isinstance(group, (str, bytes)):
|
||||
raise ValueError(f"{name} must not mix nested and flat entries")
|
||||
out.append([float(item) for item in group])
|
||||
return out
|
||||
return [[float(item) for item in value]]
|
||||
|
||||
|
||||
def minimax_h3_ref2va_video_presentation(
|
||||
tokenizer: Any,
|
||||
*,
|
||||
prompt: str,
|
||||
condition_labels: list[tuple[str, int]],
|
||||
image_token_count: int | list[int] | None,
|
||||
video_block_token_counts: list[int] | list[list[int]] | None,
|
||||
video_block_timestamps: list[float] | list[list[float]] | None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""ref2va (optionally with video refs) positive presentation:
|
||||
|
||||
per condition in request order —
|
||||
- image i: ``<Picture i>: `` label + one image vision block;
|
||||
- audio j: ``<Audio j>: `` label only (audio content never enters Qwen);
|
||||
- video k: ``<Video k>: `` label, then per temporal block a timestamp
|
||||
text ``<{t:.1f} seconds>`` followed by a VIDEO vision block
|
||||
(<|vision_start|> + <|video_pad|> x n + <|vision_end|>). Timestamps are
|
||||
the mean of each merged frame pair (Qwen3VL temporal merge 2; odd frame
|
||||
counts repeat the last frame), emitting the
|
||||
``<0.2 seconds>`` ..
|
||||
``<4.0 seconds>`` sequence — note Python bankers-rounding at .1f.
|
||||
then the verbatim prompt. Vision blocks are tagged VIDEO(0), everything
|
||||
else TEXT(1).
|
||||
"""
|
||||
if not prompt:
|
||||
raise ValueError("prompt must be non-empty")
|
||||
presentation = _Presentation()
|
||||
image_token_counts = _as_int_list(image_token_count, name="image_token_count")
|
||||
video_counts_by_ref = _as_nested_int_list(
|
||||
video_block_token_counts,
|
||||
name="video_block_token_counts",
|
||||
)
|
||||
video_timestamps_by_ref = _as_nested_float_list(
|
||||
video_block_timestamps,
|
||||
name="video_block_timestamps",
|
||||
)
|
||||
if len(video_counts_by_ref) != len(video_timestamps_by_ref):
|
||||
raise ValueError("video block token counts and timestamps must align")
|
||||
image_seen = 0
|
||||
video_seen = 0
|
||||
for cond_type, ordinal in condition_labels:
|
||||
if cond_type == "image":
|
||||
image_seen += 1
|
||||
if image_seen > len(image_token_counts):
|
||||
raise ValueError("image_token_count required for an image reference")
|
||||
count = int(image_token_counts[image_seen - 1])
|
||||
if count <= 0:
|
||||
raise ValueError("image_token_count required for an image reference")
|
||||
presentation.text(_text_ids(tokenizer, f"<Picture {ordinal}>: "))
|
||||
presentation.vision(_vision_block_ids(tokenizer, IMAGE_PAD, count))
|
||||
elif cond_type == "audio":
|
||||
presentation.text(_text_ids(tokenizer, f"<Audio {ordinal}>: "))
|
||||
elif cond_type == "video":
|
||||
video_seen += 1
|
||||
if video_seen > len(video_counts_by_ref):
|
||||
raise ValueError(
|
||||
"video reference requires block token counts and timestamps"
|
||||
)
|
||||
counts = video_counts_by_ref[video_seen - 1]
|
||||
timestamps = video_timestamps_by_ref[video_seen - 1]
|
||||
if not counts or not timestamps:
|
||||
raise ValueError(
|
||||
"video reference requires block token counts and timestamps"
|
||||
)
|
||||
presentation.text(_text_ids(tokenizer, f"<Video {ordinal}>: "))
|
||||
_timestamped_video_blocks(
|
||||
presentation,
|
||||
tokenizer,
|
||||
counts=counts,
|
||||
timestamps=timestamps,
|
||||
context="",
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported ref2va condition type {cond_type!r}")
|
||||
if image_seen != len(image_token_counts):
|
||||
raise ValueError("unused image_token_count entries")
|
||||
if video_seen != len(video_counts_by_ref):
|
||||
raise ValueError("unused video block token count entries")
|
||||
presentation.text(_text_ids(tokenizer, prompt))
|
||||
return presentation.build()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_multi_image_presentation",
|
||||
"minimax_h3_ref2va_presentation",
|
||||
"minimax_h3_ref2va_video_presentation",
|
||||
"minimax_h3_text_only_ids",
|
||||
]
|
||||
+741
@@ -0,0 +1,741 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 ref2va reference-material encoding.
|
||||
|
||||
Encoding recipes for user-provided reference materials:
|
||||
|
||||
- image reference: independent 2048px short-edge resize with upscale enabled,
|
||||
LANCZOS, and nearest-32 dimensions, then the SAME keyframe tokenizer recipe
|
||||
as fl2va (seed-42 sampled encode, normalize, [1,2,2] patchify);
|
||||
- audio reference: the audio material chain (pure
|
||||
audio is losslessly normalized
|
||||
to stereo; video soundtracks are extracted as 44.1 kHz stereo), then a
|
||||
single resample to 32 kHz,
|
||||
audio VAE posterior MEAN (encoder -> optional pre_block -> mean_proj; no
|
||||
sampling), canonical [2, T, 32], normalize with loader-injected audio stats,
|
||||
channel-major rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_audio import (
|
||||
MiniMaxH3AudioVAEArchConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.configs.models.vaes.minimax_h3_video import (
|
||||
MiniMaxH3VideoVAEArchConfig,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_SUPPORTED_FPS,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.keyframe_encoding import (
|
||||
_cached_latent_mean_std,
|
||||
minimax_h3_scoped_encode_rng,
|
||||
)
|
||||
|
||||
MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE = 2048
|
||||
MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE = 32
|
||||
MINIMAX_H3_AUDIO_SAMPLE_RATE = 32000
|
||||
MINIMAX_H3_AUDIO_CHANNELS = 2
|
||||
|
||||
|
||||
class _AudioVAEDeterminismContext:
|
||||
"""Scoped determinism config for the audio encode.
|
||||
|
||||
Disables TF32, forces deterministic algorithms, DISABLES cuDNN entirely
|
||||
for the encode (convs run on the fallback kernels), and pins SDP to the
|
||||
math backend. This configuration is required for a deterministic encode.
|
||||
Everything is restored on exit
|
||||
so the decode path keeps its own configuration.
|
||||
|
||||
Reentrant via a shared depth counter: a caller encoding several reference
|
||||
materials in one request (audio_encoding.py's per-material loop) can wrap
|
||||
the whole loop in one of these: only the outermost enter/exit actually
|
||||
touches torch.backends, and each per-material call's own nested
|
||||
with-block becomes a no-op increment/decrement instead of redundantly
|
||||
saving and restoring the same flags per material.
|
||||
"""
|
||||
|
||||
_depth = 0
|
||||
_saved: tuple | None = None
|
||||
|
||||
def __enter__(self):
|
||||
if _AudioVAEDeterminismContext._depth == 0:
|
||||
b = torch.backends
|
||||
_AudioVAEDeterminismContext._saved = (
|
||||
b.cuda.matmul.allow_tf32,
|
||||
b.cudnn.allow_tf32,
|
||||
b.cudnn.benchmark,
|
||||
b.cudnn.deterministic,
|
||||
b.cudnn.enabled,
|
||||
b.cuda.flash_sdp_enabled(),
|
||||
b.cuda.mem_efficient_sdp_enabled(),
|
||||
b.cuda.math_sdp_enabled(),
|
||||
)
|
||||
b.cuda.matmul.allow_tf32 = False
|
||||
b.cudnn.allow_tf32 = False
|
||||
b.cudnn.benchmark = False
|
||||
b.cudnn.deterministic = True
|
||||
b.cudnn.enabled = False
|
||||
b.cuda.enable_flash_sdp(False)
|
||||
b.cuda.enable_mem_efficient_sdp(False)
|
||||
b.cuda.enable_math_sdp(True)
|
||||
_AudioVAEDeterminismContext._depth += 1
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
_AudioVAEDeterminismContext._depth -= 1
|
||||
if _AudioVAEDeterminismContext._depth == 0:
|
||||
b = torch.backends
|
||||
(
|
||||
b.cuda.matmul.allow_tf32,
|
||||
b.cudnn.allow_tf32,
|
||||
b.cudnn.benchmark,
|
||||
b.cudnn.deterministic,
|
||||
b.cudnn.enabled,
|
||||
flash,
|
||||
mem_eff,
|
||||
math_sdp,
|
||||
) = _AudioVAEDeterminismContext._saved
|
||||
b.cuda.enable_flash_sdp(flash)
|
||||
b.cuda.enable_mem_efficient_sdp(mem_eff)
|
||||
b.cuda.enable_math_sdp(math_sdp)
|
||||
_AudioVAEDeterminismContext._saved = None
|
||||
|
||||
|
||||
def _nearest_multiple(value: float, multiple: int) -> int:
|
||||
return max(multiple, int(round(float(value) / multiple)) * multiple)
|
||||
|
||||
|
||||
def minimax_h3_resolve_reference_image_shape(
|
||||
*,
|
||||
width: int | float,
|
||||
height: int | float,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve a ref2va image independently from the target canvas.
|
||||
|
||||
The image keeps its display ratio, always targets a 2048px short edge (even
|
||||
when that requires upscaling), and rounds both dimensions independently to
|
||||
the nearest 32px grid. Unlike target/video ``adapt_shape_v1``, reference
|
||||
images have no area-cap branch.
|
||||
"""
|
||||
|
||||
try:
|
||||
source_width = float(width)
|
||||
source_height = float(height)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"reference image width and height must be positive finite numbers"
|
||||
) from exc
|
||||
if (
|
||||
not math.isfinite(source_width)
|
||||
or not math.isfinite(source_height)
|
||||
or source_width <= 0.0
|
||||
or source_height <= 0.0
|
||||
):
|
||||
raise ValueError(
|
||||
"reference image width and height must be positive finite numbers"
|
||||
)
|
||||
if source_width > 4.0 * source_height or source_height > 4.0 * source_width:
|
||||
raise ValueError(
|
||||
"reference image ratio must be within the inclusive range "
|
||||
f"1:4 to 4:1, got {source_width:g}x{source_height:g}"
|
||||
)
|
||||
|
||||
scale = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE / min(source_width, source_height)
|
||||
target_width = _nearest_multiple(
|
||||
source_width * scale, MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE
|
||||
)
|
||||
target_height = _nearest_multiple(
|
||||
source_height * scale, MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE
|
||||
)
|
||||
return {
|
||||
"geometry": "reference_image_resolved",
|
||||
"shape_policy_version": "reference_image_short_edge_v1",
|
||||
"base_short_edge": MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE,
|
||||
"effective_short_edge": min(target_width, target_height),
|
||||
"size_mode": "short_edge",
|
||||
"multiple": MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE,
|
||||
"rounding": "nearest",
|
||||
"allow_upscale": True,
|
||||
"width": target_width,
|
||||
"height": target_height,
|
||||
}
|
||||
|
||||
|
||||
def minimax_h3_resize_reference_image(
|
||||
image: Any,
|
||||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
) -> Any:
|
||||
"""Resize a reference image to the shape fixed by pre-queue admission."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if target_width <= 0 or target_height <= 0:
|
||||
raise ValueError("reference image target dimensions must be positive")
|
||||
if (
|
||||
target_width % MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE
|
||||
or target_height % MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE
|
||||
):
|
||||
raise ValueError(
|
||||
"reference image target dimensions must be aligned to "
|
||||
f"{MINIMAX_H3_REFERENCE_IMAGE_MULTIPLE}"
|
||||
)
|
||||
image = image.convert("RGB")
|
||||
if (target_width, target_height) == image.size:
|
||||
return image
|
||||
return image.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def _load_waveform(
|
||||
path: str,
|
||||
*,
|
||||
material_chain: str = "audio",
|
||||
max_duration_seconds: float | None = None,
|
||||
start_time_seconds: float = 0.0,
|
||||
source_sample_rate: int | None = None,
|
||||
) -> tuple[torch.Tensor, int]:
|
||||
"""Apply the audio material chain.
|
||||
|
||||
Pure-audio references preserve their source rate while normalizing to
|
||||
stereo. Video-bearing references first extract 44.1 kHz stereo PCM. The
|
||||
audio VAE boundary then performs the single 32 kHz resample below. ffmpeg
|
||||
writes bounded interleaved float PCM directly to stdout, avoiding a
|
||||
temporary lossless file plus a second decode.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
|
||||
if max_duration_seconds is not None:
|
||||
max_duration_seconds = float(max_duration_seconds)
|
||||
if not math.isfinite(max_duration_seconds) or max_duration_seconds <= 0:
|
||||
raise ValueError("reference audio duration bound must be positive")
|
||||
start_time_seconds = float(start_time_seconds)
|
||||
if not math.isfinite(start_time_seconds) or start_time_seconds < 0:
|
||||
raise ValueError("reference audio start time must be non-negative")
|
||||
|
||||
if material_chain == "audio":
|
||||
if source_sample_rate is None or int(source_sample_rate) <= 0:
|
||||
raise ValueError("reference audio sample rate must be positive")
|
||||
source_rate = int(source_sample_rate)
|
||||
elif material_chain in {
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
}:
|
||||
source_rate = 44100
|
||||
else:
|
||||
raise ValueError(
|
||||
f"unsupported MiniMax H3 audio material chain {material_chain!r}"
|
||||
)
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
]
|
||||
if start_time_seconds > 0:
|
||||
command += ["-ss", f"{start_time_seconds:.9g}"]
|
||||
command += [
|
||||
"-i",
|
||||
str(path),
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-vn",
|
||||
"-ac",
|
||||
str(MINIMAX_H3_AUDIO_CHANNELS),
|
||||
]
|
||||
if material_chain != "audio":
|
||||
command += ["-ar", str(source_rate)]
|
||||
if max_duration_seconds is not None:
|
||||
command += ["-t", f"{max_duration_seconds:.9g}"]
|
||||
command += ["-f", "f32le", "pipe:1"]
|
||||
decoded = subprocess.run(command, check=True, capture_output=True)
|
||||
payload = decoded.stdout
|
||||
if not isinstance(payload, bytes):
|
||||
raise TypeError("ffmpeg float PCM output must be bytes")
|
||||
frame_bytes = MINIMAX_H3_AUDIO_CHANNELS * torch.float32.itemsize
|
||||
if len(payload) % frame_bytes:
|
||||
raise ValueError(
|
||||
"ffmpeg returned a partial reference-audio sample frame: "
|
||||
f"{len(payload)} bytes"
|
||||
)
|
||||
waveform = torch.from_numpy(
|
||||
np.frombuffer(payload, dtype=np.float32)
|
||||
.reshape(-1, MINIMAX_H3_AUDIO_CHANNELS)
|
||||
.T.copy()
|
||||
)
|
||||
return waveform, source_rate
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _audio_resampler(source_rate: int):
|
||||
import torchaudio
|
||||
|
||||
return torchaudio.transforms.Resample(source_rate, MINIMAX_H3_AUDIO_SAMPLE_RATE)
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def minimax_h3_encode_reference_audio_rows(
|
||||
audio_vae: Any,
|
||||
audio_path: str,
|
||||
arch_config: MiniMaxH3AudioVAEArchConfig,
|
||||
*,
|
||||
material_chain: str = "audio",
|
||||
max_duration_seconds: float | None = None,
|
||||
start_time_seconds: float = 0.0,
|
||||
source_sample_rate: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Encode a reference audio file into normalized channel-major rows.
|
||||
|
||||
Returns {"rows": [2*T, 32] fp32 cpu, "ref_audio_t": T,
|
||||
"duration_seconds": float}.
|
||||
"""
|
||||
model = audio_vae
|
||||
device = next(model.parameters()).device
|
||||
waveform, source_rate = _load_waveform(
|
||||
audio_path,
|
||||
material_chain=material_chain,
|
||||
max_duration_seconds=max_duration_seconds,
|
||||
start_time_seconds=start_time_seconds,
|
||||
source_sample_rate=source_sample_rate,
|
||||
)
|
||||
if waveform.numel() == 0:
|
||||
raise ValueError(f"reference audio is empty: {audio_path}")
|
||||
if int(source_rate) != MINIMAX_H3_AUDIO_SAMPLE_RATE:
|
||||
waveform = _audio_resampler(int(source_rate))(waveform)
|
||||
waveform = waveform.to(device)
|
||||
|
||||
with _AudioVAEDeterminismContext():
|
||||
audio_data = model.preprocess(
|
||||
waveform.unsqueeze(1), MINIMAX_H3_AUDIO_SAMPLE_RATE
|
||||
)
|
||||
z = model.encoder(audio_data)
|
||||
if bool(getattr(model, "attn_proj", False)):
|
||||
z = model.pre_block(z.transpose(1, 2)).transpose(1, 2)
|
||||
if not hasattr(model, "mean_proj"):
|
||||
raise AttributeError(
|
||||
"audio VAE model must expose mean_proj for deterministic mean encoding"
|
||||
)
|
||||
latent = model.mean_proj(z).float() # [2, 32, T] or [2, T, 32]
|
||||
if latent.ndim != 3:
|
||||
raise ValueError(f"expected 3D audio latent, got {list(latent.shape)}")
|
||||
latent_channels = arch_config.latent_channels
|
||||
if int(latent.shape[-1]) != latent_channels:
|
||||
if int(latent.shape[1]) != latent_channels:
|
||||
raise ValueError(f"cannot canonicalize audio latent {list(latent.shape)}")
|
||||
latent = latent.transpose(1, 2).contiguous() # -> [2, T, 32]
|
||||
latent = latent.cpu()
|
||||
|
||||
mean, std = _cached_latent_mean_std(
|
||||
tuple(arch_config.latents_mean),
|
||||
tuple(arch_config.latents_std),
|
||||
(1, 1, latent_channels),
|
||||
)
|
||||
latent.sub_(mean).div_(std)
|
||||
rows = latent.reshape(-1, latent_channels).to(torch.float32).contiguous()
|
||||
ref_audio_t = int(latent.shape[1])
|
||||
return {
|
||||
"rows": rows,
|
||||
"ref_audio_t": ref_audio_t,
|
||||
"duration_seconds": float(waveform.shape[-1])
|
||||
/ float(MINIMAX_H3_AUDIO_SAMPLE_RATE),
|
||||
}
|
||||
|
||||
|
||||
MINIMAX_H3_PREPARED_REFERENCE_IMAGE_EXTRA_KEY = "minimax_h3_prepared_reference_image"
|
||||
|
||||
|
||||
def minimax_h3_decode_reference_video_frames(
|
||||
video_path: str,
|
||||
*,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
target_frame_count: int,
|
||||
fps: float = MINIMAX_H3_SUPPORTED_FPS,
|
||||
start_time_seconds: float = 0.0,
|
||||
) -> Any:
|
||||
"""Decode, transform, and truncate a reference video in one ffmpeg pass.
|
||||
|
||||
ffmpeg applies display rotation, CFR sampling, direct Lanczos scaling, and
|
||||
square-pixel normalization before writing bounded RGB24 frames to stdout.
|
||||
The returned array is shared by Qwen and the visual VAE, so conditioning
|
||||
never passes through a lossy x264 intermediate or a second video decode.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
|
||||
if target_frame_count <= 0:
|
||||
raise ValueError("target_frame_count must be positive")
|
||||
if target_width <= 0 or target_height <= 0:
|
||||
raise ValueError("target reference-video dimensions must be positive")
|
||||
if not math.isfinite(float(fps)) or float(fps) <= 0:
|
||||
raise ValueError("reference-video fps must be positive")
|
||||
start_time_seconds = float(start_time_seconds)
|
||||
if not math.isfinite(start_time_seconds) or start_time_seconds < 0:
|
||||
raise ValueError("reference-video start time must be non-negative")
|
||||
|
||||
filters = (
|
||||
f"fps={float(fps):g},"
|
||||
f"scale={target_width}:{target_height}:flags=lanczos,"
|
||||
"setsar=1"
|
||||
)
|
||||
command = ["ffmpeg", "-v", "error"]
|
||||
if start_time_seconds > 0:
|
||||
# Input seeking remains accurate while transcoding and avoids decoding
|
||||
# the unused prefix of a long reference into RGB frames.
|
||||
command += ["-ss", f"{start_time_seconds:.9g}"]
|
||||
command += [
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-an",
|
||||
"-vf",
|
||||
filters,
|
||||
"-frames:v",
|
||||
str(target_frame_count),
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"pipe:1",
|
||||
]
|
||||
decoded = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
payload = decoded.stdout
|
||||
if not isinstance(payload, bytes):
|
||||
raise TypeError("ffmpeg RGB24 output must be bytes")
|
||||
frame_bytes = target_width * target_height * 3
|
||||
if len(payload) % frame_bytes:
|
||||
raise ValueError(
|
||||
"ffmpeg returned a partial reference-video frame: "
|
||||
f"{len(payload)} bytes for {target_width}x{target_height} RGB24"
|
||||
)
|
||||
frame_count = len(payload) // frame_bytes
|
||||
if frame_count <= 0:
|
||||
raise ValueError(f"reference video has no frames: {video_path}")
|
||||
return np.frombuffer(payload, dtype=np.uint8).reshape(
|
||||
frame_count, target_height, target_width, 3
|
||||
)
|
||||
|
||||
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ENCODE_SEED = 42
|
||||
MINIMAX_H3_REFERENCE_VIDEO_PATCH_SIZE = (1, 2, 2)
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def minimax_h3_encode_reference_video_rows(
|
||||
video_vae: Any,
|
||||
frames: Any,
|
||||
arch_config: MiniMaxH3VideoVAEArchConfig,
|
||||
) -> tuple[torch.Tensor, int, int, int]:
|
||||
"""Encode transformed reference-video frames into packed imgvid cond rows.
|
||||
|
||||
Frames come from the request's single ffmpeg transformation pass, then use
|
||||
the SAME ``encode_videos`` recipe as the fl2va keyframe sink (fp32 weights,
|
||||
configured complete-tile parallelism, torch seed pinned at 42 because the
|
||||
encode SAMPLES the DiagonalGaussian, fp16 latent), then normalize
|
||||
and [1,2,2]-patchify. The VAE's clip_length=17 / token_drop=3 give the
|
||||
17-frames-per-5-latents temporal grouping (107 frames -> 32 latents).
|
||||
|
||||
Returns (rows [n, 96] fp32 cpu, latent_t, latent_h, latent_w).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_tokens import (
|
||||
minimax_h3_patchify_video_latent,
|
||||
)
|
||||
|
||||
frames = np.asarray(frames)
|
||||
if (
|
||||
frames.ndim != 4
|
||||
or int(frames.shape[-1]) != 3
|
||||
or frames.dtype != np.uint8
|
||||
or int(frames.shape[0]) <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"reference-video frames must be non-empty [T,H,W,3] uint8, got "
|
||||
f"shape={list(frames.shape)}, dtype={frames.dtype}"
|
||||
)
|
||||
|
||||
parameter = next(video_vae.parameters())
|
||||
prev_dtype = parameter.dtype
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(torch.float32)
|
||||
try:
|
||||
with minimax_h3_scoped_encode_rng(
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ENCODE_SEED, parameter.device
|
||||
):
|
||||
z = video_vae.encode_videos(frames, use_fp16_latent=True)[0]
|
||||
finally:
|
||||
if prev_dtype != torch.float32:
|
||||
video_vae.to(prev_dtype)
|
||||
z = z.cpu().float()
|
||||
if z.dim() == 4:
|
||||
z = z[None]
|
||||
latent_channels = arch_config.latent_channels
|
||||
if z.dim() != 5 or int(z.shape[1]) != latent_channels:
|
||||
raise ValueError(f"unexpected reference video latent shape {list(z.shape)}")
|
||||
latent_t, latent_h, latent_w = int(z.shape[2]), int(z.shape[3]), int(z.shape[4])
|
||||
mean, std = _cached_latent_mean_std(
|
||||
tuple(arch_config.latents_mean),
|
||||
tuple(arch_config.latents_std),
|
||||
(1, latent_channels, 1, 1, 1),
|
||||
)
|
||||
z.sub_(mean).div_(std)
|
||||
rows = minimax_h3_patchify_video_latent(
|
||||
z, patch_size=list(MINIMAX_H3_REFERENCE_VIDEO_PATCH_SIZE)
|
||||
)
|
||||
return rows.to(torch.float32), latent_t, latent_h, latent_w
|
||||
|
||||
|
||||
MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS = 2.0
|
||||
MINIMAX_H3_QWEN_TEMPORAL_PATCH = 2
|
||||
|
||||
|
||||
def minimax_h3_sample_reference_video_frames(
|
||||
frames: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Sample Qwen frames from the shared transformed RGB array.
|
||||
|
||||
Frame-sampling recipe (24 FPS -> 2 FPS strided view) plus the qwen3
|
||||
timestamp rule (indices padded to the
|
||||
temporal patch size with the last frame, block ts = mean of the pair at
|
||||
sample fps; text is rendered later with ``f"<{ts:.1f} seconds>"``).
|
||||
|
||||
Returns {"frames": np.ndarray TxHxWx3 u8, "block_timestamps": [float]}.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
frames = np.asarray(frames)
|
||||
if frames.ndim != 4 or int(frames.shape[0]) <= 0:
|
||||
raise ValueError(
|
||||
"Qwen reference-video sampling requires non-empty [T,H,W,C] frames"
|
||||
)
|
||||
sample_stride = int(MINIMAX_H3_SUPPORTED_FPS / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS)
|
||||
sampled_frames = frames[::sample_stride]
|
||||
ts = [
|
||||
i / MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS
|
||||
for i in range(int(sampled_frames.shape[0]))
|
||||
]
|
||||
pad = (-len(ts)) % MINIMAX_H3_QWEN_TEMPORAL_PATCH
|
||||
ts = ts + [ts[-1]] * pad
|
||||
block_timestamps = [
|
||||
(ts[i] + ts[i + MINIMAX_H3_QWEN_TEMPORAL_PATCH - 1]) / 2
|
||||
for i in range(0, len(ts), MINIMAX_H3_QWEN_TEMPORAL_PATCH)
|
||||
]
|
||||
return {"frames": sampled_frames, "block_timestamps": block_timestamps}
|
||||
|
||||
|
||||
def _reference_video_materials(plan: Any) -> list[Any]:
|
||||
return [
|
||||
m
|
||||
for m in plan.materials
|
||||
if m.material_chain
|
||||
in {"video.reference_preserve", "video_audio.reference_preserve"}
|
||||
]
|
||||
|
||||
|
||||
def _reference_video_target_frame_count(
|
||||
*,
|
||||
plan: Any,
|
||||
) -> int:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.time_request import (
|
||||
minimax_h3_align_frame_count,
|
||||
minimax_h3_frame_count_from_video_latent_t,
|
||||
)
|
||||
|
||||
shape = plan.shape
|
||||
fps = int(shape["fps"])
|
||||
duration = shape.get("duration_seconds")
|
||||
if duration is not None:
|
||||
return minimax_h3_align_frame_count(int(round(float(duration) * fps)))
|
||||
if shape.get("video_latent_t") is not None:
|
||||
return minimax_h3_frame_count_from_video_latent_t(int(shape["video_latent_t"]))
|
||||
raise ValueError(
|
||||
"reference-video preparation requires pre-queue resolved temporal dimensions"
|
||||
)
|
||||
|
||||
|
||||
def minimax_h3_prepared_reference_videos(batch: Any, plan: Any) -> dict[str, Any]:
|
||||
"""Decode the bounded reference-video RGB frames once per request.
|
||||
|
||||
BOTH the visual-condition tokenizer and Qwen consume the same transformed
|
||||
array. Its frame cap comes from the resolved target duration (17n+5 rule).
|
||||
The original path travels alongside for direct soundtrack decoding.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_PREPARED_REFERENCE_VIDEO_EXTRA_KEY,
|
||||
)
|
||||
|
||||
cached = batch.extra.get(MINIMAX_H3_PREPARED_REFERENCE_VIDEO_EXTRA_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
videos = _reference_video_materials(plan)
|
||||
if not videos:
|
||||
raise NotImplementedError(
|
||||
"ref2va video preparation requires a video or video_audio reference"
|
||||
)
|
||||
|
||||
prepared_videos = []
|
||||
for material in videos:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_localize_material_uri,
|
||||
)
|
||||
|
||||
video_path = minimax_h3_localize_material_uri(
|
||||
batch,
|
||||
material.uri,
|
||||
condition_type=material.condition_type,
|
||||
condition_index=int(material.condition_index),
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.prequeue import (
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY,
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY,
|
||||
)
|
||||
|
||||
condition_index = int(material.condition_index)
|
||||
source_facts = batch.extra.get(MINIMAX_H3_PROBE_FACTS_EXTRA_KEY, {}).get(
|
||||
condition_index
|
||||
)
|
||||
resolved_material_shape = batch.extra.get(
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY, {}
|
||||
).get(condition_index)
|
||||
if not isinstance(source_facts, dict) or not isinstance(
|
||||
resolved_material_shape, dict
|
||||
):
|
||||
raise ValueError(
|
||||
"reference-video preparation requires cached pre-queue probe "
|
||||
f"and shape facts for conditions[{condition_index}]"
|
||||
)
|
||||
input_has_audio = bool(source_facts.get("has_audio"))
|
||||
target_frames = _reference_video_target_frame_count(plan=plan)
|
||||
frames = minimax_h3_decode_reference_video_frames(
|
||||
video_path,
|
||||
target_width=int(resolved_material_shape["width"]),
|
||||
target_height=int(resolved_material_shape["height"]),
|
||||
target_frame_count=target_frames,
|
||||
fps=float(plan.shape["fps"]),
|
||||
start_time_seconds=float(material.start_time_seconds),
|
||||
)
|
||||
prepared_videos.append(
|
||||
{
|
||||
"frames": frames,
|
||||
"original_path": video_path,
|
||||
"target_frame_count": target_frames,
|
||||
"frame_count": int(frames.shape[0]),
|
||||
"condition_index": int(material.condition_index),
|
||||
"material_chain": str(material.material_chain),
|
||||
"start_time_seconds": float(material.start_time_seconds),
|
||||
"input_has_audio": input_has_audio,
|
||||
"width": int(resolved_material_shape["width"]),
|
||||
"height": int(resolved_material_shape["height"]),
|
||||
}
|
||||
)
|
||||
prepared = {
|
||||
key: value for key, value in prepared_videos[0].items() if key != "frames"
|
||||
}
|
||||
prepared["videos"] = prepared_videos
|
||||
batch.extra[MINIMAX_H3_PREPARED_REFERENCE_VIDEO_EXTRA_KEY] = prepared
|
||||
return prepared
|
||||
|
||||
|
||||
def minimax_h3_prepared_reference_image(batch: Any, plan: Any) -> dict[str, Any]:
|
||||
"""Resize ref2va image references to their pre-queue-resolved shapes.
|
||||
|
||||
Qwen (pixel_values) and the visual-condition tokenizer consume the identical
|
||||
prepared image. The runtime never recomputes geometry from ``plan.shape``;
|
||||
it consumes the per-material width/height admitted before queueing.
|
||||
"""
|
||||
cached = batch.extra.get(MINIMAX_H3_PREPARED_REFERENCE_IMAGE_EXTRA_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
images = [
|
||||
m for m in plan.materials if m.material_chain == "image.reference_preserve"
|
||||
]
|
||||
if not images:
|
||||
raise ValueError("ref2va requires at least one image reference")
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_localize_material_uri,
|
||||
)
|
||||
|
||||
prepared_images = []
|
||||
for material in images:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.prequeue import (
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY,
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY,
|
||||
)
|
||||
|
||||
condition_index = int(material.condition_index)
|
||||
source_facts = batch.extra.get(MINIMAX_H3_PROBE_FACTS_EXTRA_KEY, {}).get(
|
||||
condition_index
|
||||
)
|
||||
resolved_shape = batch.extra.get(
|
||||
MINIMAX_H3_RESOLVED_MATERIAL_SHAPES_EXTRA_KEY, {}
|
||||
).get(condition_index)
|
||||
if not isinstance(source_facts, dict) or not isinstance(resolved_shape, dict):
|
||||
raise ValueError(
|
||||
"reference-image preparation requires cached pre-queue probe "
|
||||
f"and shape facts for conditions[{condition_index}]"
|
||||
)
|
||||
image_path = minimax_h3_localize_material_uri(
|
||||
batch,
|
||||
material.uri,
|
||||
condition_type=material.condition_type,
|
||||
condition_index=condition_index,
|
||||
)
|
||||
with Image.open(image_path) as source_image:
|
||||
image = ImageOps.exif_transpose(source_image).convert("RGB")
|
||||
expected_size = (
|
||||
int(resolved_shape["width"]),
|
||||
int(resolved_shape["height"]),
|
||||
)
|
||||
prepared_image = minimax_h3_resize_reference_image(
|
||||
image,
|
||||
target_width=expected_size[0],
|
||||
target_height=expected_size[1],
|
||||
)
|
||||
if prepared_image.size != expected_size:
|
||||
raise ValueError(
|
||||
"reference image preparation disagrees with pre-queue shape: "
|
||||
f"expected={expected_size}, actual={prepared_image.size}"
|
||||
)
|
||||
prepared_images.append(
|
||||
{
|
||||
"image": prepared_image,
|
||||
"condition_index": condition_index,
|
||||
}
|
||||
)
|
||||
prepared = {
|
||||
# single-image consumers keep the existing keys
|
||||
"image": prepared_images[0]["image"],
|
||||
"condition_index": prepared_images[0]["condition_index"],
|
||||
"images": prepared_images,
|
||||
}
|
||||
batch.extra[MINIMAX_H3_PREPARED_REFERENCE_IMAGE_EXTRA_KEY] = prepared
|
||||
return prepared
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_decode_reference_video_frames",
|
||||
"minimax_h3_encode_reference_audio_rows",
|
||||
"minimax_h3_encode_reference_video_rows",
|
||||
"minimax_h3_prepared_reference_image",
|
||||
"minimax_h3_prepared_reference_videos",
|
||||
"minimax_h3_resolve_reference_image_shape",
|
||||
"minimax_h3_sample_reference_video_frames",
|
||||
]
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Public MiniMax H3 model-index admission contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_QUALITY_PROFILES,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
canonical_minimax_h3_task,
|
||||
partition_for_task,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
_MINIMAX_H3_QUALITY_WORKLOAD = {
|
||||
"task": "t2va",
|
||||
"width": 1344,
|
||||
"height": 768,
|
||||
"fps": 24,
|
||||
"frame_count": 124,
|
||||
"num_inference_steps": 50,
|
||||
"flow_shift": 12.0,
|
||||
"audio_flow_shift": 3.0,
|
||||
}
|
||||
|
||||
|
||||
def _string_list(value: Any, path: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError(f"{path} must be a non-empty list")
|
||||
values = tuple(value)
|
||||
if any(not isinstance(item, str) or not item for item in values):
|
||||
raise ValueError(f"{path} must contain non-empty strings")
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError(f"{path} must not contain duplicates")
|
||||
return values
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MiniMaxH3ReleaseMetadata:
|
||||
schema_version: int
|
||||
partition: str
|
||||
tasks: tuple[str, ...]
|
||||
task_aliases: Mapping[str, str]
|
||||
video_sigma_shift: float
|
||||
audio_sigma_shift: float
|
||||
|
||||
@classmethod
|
||||
def from_model_index(
|
||||
cls, model_index: Mapping[str, Any]
|
||||
) -> MiniMaxH3ReleaseMetadata:
|
||||
raw = model_index.get("_minimax_h3")
|
||||
if not isinstance(raw, Mapping):
|
||||
raise ValueError("model_index.json._minimax_h3 must be an object")
|
||||
if raw.get("schema_version") != 1:
|
||||
raise ValueError("model_index.json._minimax_h3.schema_version must be 1")
|
||||
partition = raw.get("partition")
|
||||
if partition not in {"fl2va", "ref2va"}:
|
||||
raise ValueError(
|
||||
"model_index.json._minimax_h3.partition must be one of " "fl2va, ref2va"
|
||||
)
|
||||
tasks = _string_list(raw.get("tasks"), "model_index.json._minimax_h3.tasks")
|
||||
aliases = raw.get("task_aliases", {})
|
||||
if not isinstance(aliases, Mapping) or any(
|
||||
not isinstance(key, str)
|
||||
or not key
|
||||
or not isinstance(value, str)
|
||||
or not value
|
||||
for key, value in aliases.items()
|
||||
):
|
||||
raise ValueError(
|
||||
"model_index.json._minimax_h3.task_aliases must map strings to strings"
|
||||
)
|
||||
scales = raw.get("sigma_shift_scales")
|
||||
if not isinstance(scales, Mapping):
|
||||
raise ValueError(
|
||||
"model_index.json._minimax_h3.sigma_shift_scales must be an object"
|
||||
)
|
||||
try:
|
||||
video_sigma = float(scales["video"])
|
||||
audio_sigma = float(scales["audio"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"model_index.json._minimax_h3.sigma_shift_scales requires numeric "
|
||||
"video and audio values"
|
||||
) from exc
|
||||
metadata = cls(
|
||||
schema_version=1,
|
||||
partition=partition,
|
||||
tasks=tasks,
|
||||
task_aliases=dict(aliases),
|
||||
video_sigma_shift=video_sigma,
|
||||
audio_sigma_shift=audio_sigma,
|
||||
)
|
||||
for task in metadata.tasks:
|
||||
if canonical_minimax_h3_task(task) != task:
|
||||
raise ValueError(
|
||||
f"tasks must contain canonical task names, got {task!r}"
|
||||
)
|
||||
if partition_for_task(task) != partition:
|
||||
raise ValueError(
|
||||
f"task {task!r} does not belong to partition {partition!r}"
|
||||
)
|
||||
for alias, target in metadata.task_aliases.items():
|
||||
if target not in metadata.tasks:
|
||||
raise ValueError(
|
||||
f"task alias {alias!r} targets undeclared task {target!r}"
|
||||
)
|
||||
if canonical_minimax_h3_task(alias) != target:
|
||||
raise ValueError(
|
||||
f"unsupported task alias mapping {alias!r} -> {target!r}"
|
||||
)
|
||||
return metadata
|
||||
|
||||
@property
|
||||
def sigma_shift_scales(self) -> dict[str, float]:
|
||||
return {"video": self.video_sigma_shift, "audio": self.audio_sigma_shift}
|
||||
|
||||
def canonical_task(self, task: str) -> str:
|
||||
normalized = task.strip().lower()
|
||||
canonical = self.task_aliases.get(normalized, normalized)
|
||||
if canonical not in self.tasks:
|
||||
raise ValueError(
|
||||
f"task {task!r} is not served by MiniMax H3 partition {self.partition!r}; "
|
||||
f"supported tasks: {list(self.tasks)!r}"
|
||||
)
|
||||
if partition_for_task(canonical) != self.partition:
|
||||
raise ValueError(
|
||||
f"task {task!r} resolves outside partition {self.partition!r}"
|
||||
)
|
||||
return canonical
|
||||
|
||||
|
||||
class MiniMaxH3PartitionAdmissionStage(PipelineStage):
|
||||
def __init__(self, metadata: MiniMaxH3ReleaseMetadata) -> None:
|
||||
super().__init__()
|
||||
self.metadata = metadata
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
task = None if batch.sampling_params is None else batch.sampling_params.task
|
||||
if not isinstance(task, str) or not task.strip():
|
||||
raise ValueError("MiniMax H3 request task must be a non-empty string")
|
||||
self.metadata.canonical_task(task)
|
||||
quality = getattr(batch.sampling_params, "quality", "lossless")
|
||||
if quality not in MINIMAX_H3_QUALITY_PROFILES:
|
||||
raise ValueError(
|
||||
f"unsupported MiniMax-H3 quality profile {quality!r}; supported: "
|
||||
f"{list(MINIMAX_H3_QUALITY_PROFILES)}"
|
||||
)
|
||||
approximate = quality != "lossless"
|
||||
attention_backend = str(server_args.attention_backend or "").strip().lower()
|
||||
if attention_backend == "sage_attn" and not batch.is_warmup:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 does not support SageAttention: the current packed "
|
||||
"varlen path does not preserve model output"
|
||||
)
|
||||
if approximate and not batch.is_warmup:
|
||||
server_args.pipeline_config.validate_quality_deployment(server_args)
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 approximate quality profiles require a resolved "
|
||||
"request plan"
|
||||
)
|
||||
shape = plan.shape
|
||||
actual = {
|
||||
"task": plan.task,
|
||||
"width": int(shape["width"]),
|
||||
"height": int(shape["height"]),
|
||||
"fps": int(shape["fps"]),
|
||||
"frame_count": int(shape["frame_count"]),
|
||||
"num_inference_steps": int(batch.num_inference_steps),
|
||||
"flow_shift": float(
|
||||
plan.flow_shift
|
||||
if plan.flow_shift is not None
|
||||
else plan.default_flow_shift
|
||||
),
|
||||
"audio_flow_shift": float(
|
||||
plan.audio_flow_shift
|
||||
if plan.audio_flow_shift is not None
|
||||
else plan.default_audio_flow_shift
|
||||
),
|
||||
}
|
||||
exact_fields = (
|
||||
"task",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"frame_count",
|
||||
"num_inference_steps",
|
||||
)
|
||||
exact = all(
|
||||
actual[name] == _MINIMAX_H3_QUALITY_WORKLOAD[name]
|
||||
for name in exact_fields
|
||||
)
|
||||
shifts = math.isclose(
|
||||
actual["flow_shift"],
|
||||
_MINIMAX_H3_QUALITY_WORKLOAD["flow_shift"],
|
||||
abs_tol=1e-9,
|
||||
) and math.isclose(
|
||||
actual["audio_flow_shift"],
|
||||
_MINIMAX_H3_QUALITY_WORKLOAD["audio_flow_shift"],
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
if not exact or not shifts:
|
||||
raise ValueError(
|
||||
"MiniMax-H3 approximate quality profiles are validated only for "
|
||||
f"{_MINIMAX_H3_QUALITY_WORKLOAD}; got {actual}"
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3PartitionAdmissionStage", "MiniMaxH3ReleaseMetadata"]
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 canonical request validation.
|
||||
|
||||
Entry fail-fast for `minimax_h3.request/v1`: every violation raises ValueError
|
||||
with the offending field path. Output is a normalized canonical dict (frame
|
||||
indices validated but semantic -1 preserved, nothing else rewritten — prompt passes through verbatim and
|
||||
conditions order is semantic, never reordered).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_MAX_DURATION_SECONDS,
|
||||
MINIMAX_H3_MIN_DURATION_SECONDS,
|
||||
MINIMAX_H3_SUPPORTED_FPS,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_CONDITION_ROLE_KEYFRAME,
|
||||
MINIMAX_H3_CONDITION_ROLE_REFERENCE,
|
||||
MINIMAX_H3_FINITE_ASPECT_RATIOS,
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
MINIMAX_H3_TASK_FL2VA,
|
||||
MINIMAX_H3_TASK_REF2VA,
|
||||
MINIMAX_H3_TASK_T2VA,
|
||||
MiniMaxH3TaskProfile,
|
||||
canonical_minimax_h3_task,
|
||||
minimax_h3_task_profile,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.time_request import (
|
||||
minimax_h3_align_frame_count,
|
||||
)
|
||||
|
||||
MINIMAX_H3_REQUEST_SCHEMA = "minimax_h3.request/v1"
|
||||
MINIMAX_H3_MAX_SIGNED_SEED = (1 << 63) - 1
|
||||
_ALLOWED_CONDITION_KEYS = frozenset(
|
||||
{"type", "uri", "role", "frame_index", "start_time_seconds"}
|
||||
)
|
||||
|
||||
|
||||
def _require_str(value: Any, path: str) -> str:
|
||||
if not isinstance(value, str) or value == "":
|
||||
raise ValueError(f"{path} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(value: Any, path: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{path} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _optional_positive_finite_float(value: Any, path: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{path} must be a number")
|
||||
normalized = float(value)
|
||||
if not math.isfinite(normalized) or normalized <= 0.0:
|
||||
raise ValueError(f"{path} must be a positive finite number")
|
||||
return normalized
|
||||
|
||||
|
||||
def _optional_nonnegative_finite_float(value: Any, path: str) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{path} must be a number")
|
||||
normalized = float(value)
|
||||
if not math.isfinite(normalized) or normalized < 0.0:
|
||||
raise ValueError(f"{path} must be a non-negative finite number")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_target(target: Any, *, profile: MiniMaxH3TaskProfile) -> dict[str, Any]:
|
||||
path = "target"
|
||||
if not isinstance(target, Mapping):
|
||||
raise ValueError(f"{path} is required and must be an object")
|
||||
# The canonical target has a deliberately small projection. Transport
|
||||
# compatibility keys are ignored; only these three declared values are
|
||||
# validated and emitted below.
|
||||
short_edge = _require_int(target.get("short_edge"), f"{path}.short_edge")
|
||||
if short_edge != 768:
|
||||
raise ValueError(
|
||||
f"{path}.short_edge must be 768 for minimax_h3, got {short_edge}"
|
||||
)
|
||||
aspect_ratio = _require_str(target.get("aspect_ratio"), f"{path}.aspect_ratio")
|
||||
if profile.aspect_ratio_forced_auto and aspect_ratio != "auto":
|
||||
raise ValueError(
|
||||
f'{path}.aspect_ratio must be "auto" for task {profile.task!r}, '
|
||||
f"got {aspect_ratio!r}"
|
||||
)
|
||||
has_duration = target.get("duration_seconds") is not None
|
||||
if (
|
||||
profile.task in {MINIMAX_H3_TASK_T2VA, MINIMAX_H3_TASK_REF2VA}
|
||||
and aspect_ratio != "auto"
|
||||
and aspect_ratio not in MINIMAX_H3_FINITE_ASPECT_RATIOS
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path}.aspect_ratio for task {profile.task!r} must be 'auto' or "
|
||||
f"one of {list(MINIMAX_H3_FINITE_ASPECT_RATIOS)!r}, got "
|
||||
f"{aspect_ratio!r}"
|
||||
)
|
||||
if not has_duration:
|
||||
if not profile.duration_from_audio_reference:
|
||||
raise ValueError(f"{path}.duration_seconds is required")
|
||||
# ref2va: duration may derive from a reference audio; the
|
||||
# audio-condition presence is enforced after conditions validate.
|
||||
out: dict[str, Any] = {
|
||||
"short_edge": short_edge,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
}
|
||||
if has_duration:
|
||||
duration = target["duration_seconds"]
|
||||
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
|
||||
raise ValueError(f"{path}.duration_seconds must be a number")
|
||||
if duration <= 0:
|
||||
raise ValueError(f"{path}.duration_seconds must be positive")
|
||||
if not (
|
||||
MINIMAX_H3_MIN_DURATION_SECONDS
|
||||
<= float(duration)
|
||||
<= MINIMAX_H3_MAX_DURATION_SECONDS
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path}.duration_seconds must be in "
|
||||
f"[{MINIMAX_H3_MIN_DURATION_SECONDS:g}, "
|
||||
f"{MINIMAX_H3_MAX_DURATION_SECONDS:g}], got {duration}"
|
||||
)
|
||||
out["duration_seconds"] = float(duration)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_conditions(
|
||||
conditions: Any,
|
||||
*,
|
||||
profile: MiniMaxH3TaskProfile,
|
||||
frame_count: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
path = "conditions"
|
||||
if conditions is None:
|
||||
conditions = []
|
||||
if not isinstance(conditions, Sequence) or isinstance(conditions, (str, bytes)):
|
||||
raise ValueError(f"{path} must be a list")
|
||||
|
||||
if not profile.conditions_required:
|
||||
if len(conditions) > 0:
|
||||
raise ValueError(
|
||||
f"{path} must be empty for task {profile.task!r} "
|
||||
f"(got {len(conditions)} entries)"
|
||||
)
|
||||
return []
|
||||
if len(conditions) == 0:
|
||||
raise ValueError(
|
||||
f"{path} requires at least one entry for task {profile.task!r}"
|
||||
)
|
||||
if (
|
||||
profile.min_condition_count is not None
|
||||
and len(conditions) < profile.min_condition_count
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path} requires at least {profile.min_condition_count} entries "
|
||||
f"for task {profile.task!r}, got {len(conditions)}"
|
||||
)
|
||||
if (
|
||||
profile.max_condition_count is not None
|
||||
and len(conditions) > profile.max_condition_count
|
||||
):
|
||||
raise ValueError(
|
||||
f"{path} allows at most {profile.max_condition_count} entries "
|
||||
f"for task {profile.task!r}, got {len(conditions)}"
|
||||
)
|
||||
|
||||
aligned_frame_count = (
|
||||
minimax_h3_align_frame_count(frame_count) if frame_count is not None else None
|
||||
)
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen_frame_indices: dict[int, int] = {}
|
||||
for index, cond in enumerate(conditions):
|
||||
cpath = f"{path}[{index}]"
|
||||
if not isinstance(cond, Mapping):
|
||||
raise ValueError(f"{cpath} must be an object")
|
||||
unknown = set(cond) - _ALLOWED_CONDITION_KEYS
|
||||
if unknown:
|
||||
raise ValueError(f"{cpath} has unknown fields: {sorted(unknown)}")
|
||||
role = _require_str(cond.get("role"), f"{cpath}.role")
|
||||
if role not in (
|
||||
MINIMAX_H3_CONDITION_ROLE_KEYFRAME,
|
||||
MINIMAX_H3_CONDITION_ROLE_REFERENCE,
|
||||
):
|
||||
raise ValueError(
|
||||
f"{cpath}.role must be keyframe or reference, " f"got {role!r}"
|
||||
)
|
||||
cond_type = _require_str(cond.get("type"), f"{cpath}.type")
|
||||
try:
|
||||
rule = profile.rule_for(role=role, condition_type=cond_type)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{cpath}: {exc}") from exc
|
||||
uri = _require_str(cond.get("uri"), f"{cpath}.uri")
|
||||
|
||||
entry: dict[str, Any] = {"type": cond_type, "uri": uri, "role": role}
|
||||
if rule.requires_frame_index:
|
||||
frame_index = _require_int(cond.get("frame_index"), f"{cpath}.frame_index")
|
||||
if aligned_frame_count is None:
|
||||
raise ValueError(
|
||||
f"{cpath}.frame_index requires a resolved target duration"
|
||||
)
|
||||
if frame_index == -1:
|
||||
resolved = aligned_frame_count - 1
|
||||
elif 0 <= frame_index < aligned_frame_count:
|
||||
resolved = frame_index
|
||||
else:
|
||||
raise ValueError(
|
||||
f"{cpath}.frame_index must be -1 or in "
|
||||
f"[0, {aligned_frame_count}) after 17n+5 frame alignment, "
|
||||
f"got {frame_index}"
|
||||
)
|
||||
if resolved in seen_frame_indices:
|
||||
raise ValueError(
|
||||
f"{cpath}.frame_index resolves to {resolved}, already "
|
||||
f"bound by conditions[{seen_frame_indices[resolved]}]"
|
||||
)
|
||||
seen_frame_indices[resolved] = index
|
||||
# Preserve the request-level semantic index. In particular, -1 is
|
||||
# the canonical last-frame sentinel; the resolved pixel frame is
|
||||
# carried separately by MiniMaxH3ResolvedPlan.
|
||||
entry["frame_index"] = frame_index
|
||||
elif cond.get("frame_index") is not None:
|
||||
raise ValueError(f"{cpath}.frame_index is not allowed for role={role!r}")
|
||||
start_time_seconds = _optional_nonnegative_finite_float(
|
||||
cond.get("start_time_seconds"), f"{cpath}.start_time_seconds"
|
||||
)
|
||||
if start_time_seconds is not None:
|
||||
if cond_type not in {"video", "video_audio"}:
|
||||
raise ValueError(
|
||||
f"{cpath}.start_time_seconds is only allowed for video "
|
||||
"or video_audio references"
|
||||
)
|
||||
entry["start_time_seconds"] = start_time_seconds
|
||||
normalized.append(entry)
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_fl2va_conditions(conditions: Sequence[Mapping[str, Any]]) -> None:
|
||||
"""Enforce the public FL contract after per-entry schema validation."""
|
||||
|
||||
frame_indices = tuple(condition.get("frame_index") for condition in conditions)
|
||||
if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"conditions for task 'fl2va' must be one or two ordered "
|
||||
"image/keyframe entries with frame_index [0], [-1], or [0, -1], "
|
||||
f"got {list(frame_indices)!r}"
|
||||
)
|
||||
|
||||
|
||||
def minimax_h3_validate_canonical_request(
|
||||
*,
|
||||
task: Any,
|
||||
prompt: Any,
|
||||
conditions: Any,
|
||||
target: Any,
|
||||
flow_shift: Any = None,
|
||||
audio_flow_shift: Any = None,
|
||||
seed: Any = None,
|
||||
**_extra_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate and normalize a `minimax_h3.request/v1` canonical request.
|
||||
|
||||
Returns the normalized canonical dict; raises ValueError with a field
|
||||
path on any violation. Conditions order is preserved (it is semantic:
|
||||
prompt ordinal labels reference it). seed=0 is a legal value.
|
||||
"""
|
||||
# Accept transport wrappers and compatibility kwargs at this boundary, but
|
||||
# never copy them into the canonical request.
|
||||
del _extra_kwargs
|
||||
# Normalize the task name before profile lookup so offline callers match
|
||||
# the adapter behaviour.
|
||||
task_name = canonical_minimax_h3_task(_require_str(task, "task"))
|
||||
profile = minimax_h3_task_profile(task_name)
|
||||
prompt_text = _require_str(prompt, "prompt")
|
||||
|
||||
normalized_target = _validate_target(target, profile=profile)
|
||||
requested_frame_count = None
|
||||
if normalized_target.get("duration_seconds") is not None:
|
||||
requested_frame_count = int(
|
||||
round(
|
||||
float(normalized_target["duration_seconds"]) * MINIMAX_H3_SUPPORTED_FPS
|
||||
)
|
||||
)
|
||||
normalized_conditions = _validate_conditions(
|
||||
conditions,
|
||||
profile=profile,
|
||||
frame_count=requested_frame_count,
|
||||
)
|
||||
if profile.task == MINIMAX_H3_TASK_FL2VA:
|
||||
_validate_fl2va_conditions(normalized_conditions)
|
||||
# ref2va accepts ordered material streams containing any mix of
|
||||
# image/audio/video/video_audio references. Type admission is handled by
|
||||
# the task profile; temporal ambiguity is validated later when target
|
||||
# duration is omitted.
|
||||
if not profile.video_reference_supported:
|
||||
for index, cond in enumerate(normalized_conditions):
|
||||
if cond["type"] in ("video", "video_audio"):
|
||||
raise ValueError(
|
||||
f"conditions[{index}]: video references are not supported "
|
||||
f"in v1 for task {profile.task!r} (image/audio only)"
|
||||
)
|
||||
if normalized_target.get("duration_seconds") is None:
|
||||
# Only reachable for duration_from_audio_reference profiles.
|
||||
duration_sources = [
|
||||
cond
|
||||
for cond in normalized_conditions
|
||||
if cond["type"] in ("audio", "video", "video_audio")
|
||||
]
|
||||
if not duration_sources:
|
||||
raise ValueError(
|
||||
"target.duration_seconds is required, or exactly one "
|
||||
"audio reference to derive duration from (including "
|
||||
f"video/video_audio soundtracks; task {profile.task!r})"
|
||||
)
|
||||
if len(duration_sources) > 1:
|
||||
raise ValueError(
|
||||
"target.duration_seconds is required when multiple "
|
||||
"audio-bearing references are provided"
|
||||
)
|
||||
|
||||
canonical: dict[str, Any] = {
|
||||
"schema": MINIMAX_H3_REQUEST_SCHEMA,
|
||||
"task": task_name,
|
||||
"prompt": prompt_text,
|
||||
"conditions": normalized_conditions,
|
||||
"target": normalized_target,
|
||||
}
|
||||
normalized_flow_shift = _optional_positive_finite_float(flow_shift, "flow_shift")
|
||||
normalized_audio_flow_shift = _optional_positive_finite_float(
|
||||
audio_flow_shift, "audio_flow_shift"
|
||||
)
|
||||
if normalized_flow_shift is not None:
|
||||
canonical["flow_shift"] = normalized_flow_shift
|
||||
if normalized_audio_flow_shift is not None:
|
||||
canonical["audio_flow_shift"] = normalized_audio_flow_shift
|
||||
if seed is not None:
|
||||
normalized_seed = _require_int(seed, "seed")
|
||||
if normalized_seed < 0:
|
||||
raise ValueError(f"seed must be non-negative, got {normalized_seed}")
|
||||
if normalized_seed > MINIMAX_H3_MAX_SIGNED_SEED:
|
||||
raise ValueError(
|
||||
f"seed must not exceed the signed int64 maximum, got {normalized_seed}"
|
||||
)
|
||||
canonical["seed"] = normalized_seed
|
||||
return canonical
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_REQUEST_SCHEMA",
|
||||
"MINIMAX_H3_MAX_SIGNED_SEED",
|
||||
"MINIMAX_H3_SUPPORTED_FPS",
|
||||
"minimax_h3_validate_canonical_request",
|
||||
]
|
||||
+447
@@ -0,0 +1,447 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 ResolvedPlan: the data-only per-request execution plan.
|
||||
|
||||
`minimax_h3_resolve_plan` turns a validated canonical request (see
|
||||
request_validation.py) into the data-only plan consumed by stages 1-8.
|
||||
Stages never branch on task names; skips must be explicit in the plan.
|
||||
|
||||
Scope notes (adapt_shape_v1):
|
||||
- all target and material-derived ratios use the single adaptive spatial
|
||||
resolver exported by this module. It starts from a 768px nominal short edge,
|
||||
applies the 768x1344 soft area cap, then rounds both axes independently to
|
||||
the nearest 32px grid.
|
||||
- ``auto`` uses the task profile: t2va/ref2va resolve to the 16:9 policy
|
||||
default, while fl2va defers geometry until material probe facts are
|
||||
available. Consumers must fail fast if required evidence is missing.
|
||||
- per-modality request overrides and task defaults are retained separately so
|
||||
the timestep stage can apply request > model config > task default priority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_SUPPORTED_FPS,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
minimax_h3_task_profile,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.time_request import (
|
||||
minimax_h3_align_frame_count,
|
||||
minimax_h3_audio_latent_t,
|
||||
minimax_h3_video_latent_t,
|
||||
)
|
||||
|
||||
MINIMAX_H3_SHAPE_POLICY_VERSION = "adapt_shape_v1"
|
||||
MINIMAX_H3_BASE_SHORT_EDGE = 768
|
||||
MINIMAX_H3_MAX_PIXELS = MINIMAX_H3_BASE_SHORT_EDGE * 1344
|
||||
MINIMAX_H3_CANVAS_MULTIPLE = 32
|
||||
MINIMAX_H3_MIN_ASPECT_RATIO = 1.0 / 4.0
|
||||
MINIMAX_H3_MAX_ASPECT_RATIO = 4.0
|
||||
|
||||
|
||||
class MiniMaxH3MaterialPlanItem(msgspec.Struct, frozen=True):
|
||||
condition_index: int
|
||||
role: str
|
||||
condition_type: str
|
||||
uri: str
|
||||
material_chain: str
|
||||
# Request-level semantic frame index. -1 remains the last-frame sentinel.
|
||||
frame_index: int | None = None
|
||||
# Concrete pixel-frame index after target 17n+5 alignment.
|
||||
resolved_frame_index: int | None = None
|
||||
# Per-reference seek applied identically to the visual and audio streams.
|
||||
start_time_seconds: float = 0.0
|
||||
|
||||
|
||||
class MiniMaxH3ResolvedPlan(msgspec.Struct, frozen=True):
|
||||
task: str
|
||||
prompt: str
|
||||
seed: int | None
|
||||
materials: tuple[MiniMaxH3MaterialPlanItem, ...]
|
||||
encoders: dict
|
||||
branches: tuple[dict, ...]
|
||||
default_flow_shift: float
|
||||
default_audio_flow_shift: float
|
||||
flow_shift: float | None
|
||||
audio_flow_shift: float | None
|
||||
shape: dict
|
||||
condition_mask: dict
|
||||
|
||||
|
||||
def _parse_aspect_ratio(value: str) -> tuple[int, int]:
|
||||
parts = value.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"target.aspect_ratio must be 'W:H' or 'auto', got {value!r}")
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"target.aspect_ratio must be integer 'W:H', got {value!r}"
|
||||
) from exc
|
||||
if w <= 0 or h <= 0:
|
||||
raise ValueError(
|
||||
f"target.aspect_ratio components must be positive, got {value!r}"
|
||||
)
|
||||
return w, h
|
||||
|
||||
|
||||
def _nearest_multiple(value: float, multiple: int) -> int:
|
||||
return max(multiple, int(round(float(value) / multiple)) * multiple)
|
||||
|
||||
|
||||
def _validate_base_short_edge(value: Any) -> int:
|
||||
try:
|
||||
short_edge = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("target.short_edge must be 768") from exc
|
||||
if short_edge != MINIMAX_H3_BASE_SHORT_EDGE or value != short_edge:
|
||||
raise ValueError(
|
||||
f"target.short_edge must be 768 for MiniMax H3 shape policy v2, got {value!r}"
|
||||
)
|
||||
return short_edge
|
||||
|
||||
|
||||
def minimax_h3_resolve_spatial_shape(
|
||||
*,
|
||||
width: int | float,
|
||||
height: int | float,
|
||||
base_short_edge: int = MINIMAX_H3_BASE_SHORT_EDGE,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve one display ratio with the ``adapt_shape_v1`` math.
|
||||
|
||||
This is the only implementation of adaptive target geometry. Callers may
|
||||
pass an explicit aspect-ratio pair or probed display dimensions; only the
|
||||
ratio is significant. The supported ratio range is inclusive 1:4 to 4:1.
|
||||
The returned dimensions are always 32px aligned; nearest-grid rounding may
|
||||
leave the final area slightly above the pre-round soft pixel budget.
|
||||
"""
|
||||
base_short_edge = _validate_base_short_edge(base_short_edge)
|
||||
try:
|
||||
source_width = float(width)
|
||||
source_height = float(height)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
"shape width and height must be positive finite numbers"
|
||||
) from exc
|
||||
if (
|
||||
not math.isfinite(source_width)
|
||||
or not math.isfinite(source_height)
|
||||
or source_width <= 0.0
|
||||
or source_height <= 0.0
|
||||
):
|
||||
raise ValueError("shape width and height must be positive finite numbers")
|
||||
|
||||
ratio = source_width / source_height
|
||||
if not math.isfinite(ratio) or ratio <= 0.0:
|
||||
raise ValueError("shape ratio must be a positive finite number")
|
||||
if not MINIMAX_H3_MIN_ASPECT_RATIO <= ratio <= MINIMAX_H3_MAX_ASPECT_RATIO:
|
||||
raise ValueError(
|
||||
"adapt_shape_v1 ratio must be within the inclusive range "
|
||||
f"1:4 to 4:1, got {source_width:g}:{source_height:g}"
|
||||
)
|
||||
|
||||
if ratio >= 1.0:
|
||||
nominal_width = float(base_short_edge) * ratio
|
||||
nominal_height = float(base_short_edge)
|
||||
else:
|
||||
nominal_width = float(base_short_edge)
|
||||
nominal_height = float(base_short_edge) / ratio
|
||||
nominal_area = nominal_width * nominal_height
|
||||
if nominal_area > MINIMAX_H3_MAX_PIXELS:
|
||||
size_mode = "area"
|
||||
scale = math.sqrt(float(MINIMAX_H3_MAX_PIXELS) / nominal_area)
|
||||
nominal_width *= scale
|
||||
nominal_height *= scale
|
||||
else:
|
||||
size_mode = "short_edge"
|
||||
|
||||
resolved_width = _nearest_multiple(nominal_width, MINIMAX_H3_CANVAS_MULTIPLE)
|
||||
resolved_height = _nearest_multiple(nominal_height, MINIMAX_H3_CANVAS_MULTIPLE)
|
||||
|
||||
return {
|
||||
"geometry": "resolved_v2",
|
||||
"shape_policy_version": MINIMAX_H3_SHAPE_POLICY_VERSION,
|
||||
"base_short_edge": base_short_edge,
|
||||
"effective_short_edge": min(resolved_width, resolved_height),
|
||||
"size_mode": size_mode,
|
||||
"max_pixels": MINIMAX_H3_MAX_PIXELS,
|
||||
"multiple": MINIMAX_H3_CANVAS_MULTIPLE,
|
||||
"rounding": "nearest",
|
||||
"width": resolved_width,
|
||||
"height": resolved_height,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_shape(
|
||||
target: Mapping[str, Any],
|
||||
*,
|
||||
geometry_source: str,
|
||||
auto_aspect_ratio: str | None = None,
|
||||
auto_geometry_source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
fps = MINIMAX_H3_SUPPORTED_FPS
|
||||
if "duration_seconds" not in target:
|
||||
# ref2va duration_from_audio_reference: temporal shape resolves at
|
||||
# material time from the reference audio probe. Validation
|
||||
# guarantees an audio condition exists.
|
||||
shape: dict[str, Any] = {
|
||||
"fps": fps,
|
||||
"temporal": "deferred_from_audio_reference",
|
||||
"geometry_source": geometry_source,
|
||||
}
|
||||
return _resolve_spatial(
|
||||
shape,
|
||||
target,
|
||||
auto_aspect_ratio=auto_aspect_ratio,
|
||||
auto_geometry_source=auto_geometry_source,
|
||||
)
|
||||
frame_count = minimax_h3_align_frame_count(
|
||||
int(round(float(target["duration_seconds"]) * fps))
|
||||
)
|
||||
duration_seconds = frame_count / fps
|
||||
shape = {
|
||||
"fps": fps,
|
||||
"frame_count": frame_count,
|
||||
"video_latent_t": minimax_h3_video_latent_t(frame_count),
|
||||
"audio_latent_t": minimax_h3_audio_latent_t(duration_seconds),
|
||||
"geometry_source": geometry_source,
|
||||
}
|
||||
return _resolve_spatial(
|
||||
shape,
|
||||
target,
|
||||
auto_aspect_ratio=auto_aspect_ratio,
|
||||
auto_geometry_source=auto_geometry_source,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_spatial(
|
||||
shape: dict[str, Any],
|
||||
target: Mapping[str, Any],
|
||||
*,
|
||||
auto_aspect_ratio: str | None,
|
||||
auto_geometry_source: str | None,
|
||||
) -> dict[str, Any]:
|
||||
aspect_ratio = str(target["aspect_ratio"])
|
||||
base_short_edge = _validate_base_short_edge(target.get("short_edge"))
|
||||
if aspect_ratio == "auto":
|
||||
if auto_aspect_ratio is None:
|
||||
# Deferred: canvas comes from material/model geometry at prepare time.
|
||||
shape["geometry"] = "deferred"
|
||||
shape["geometry_source"] = auto_geometry_source or shape["geometry_source"]
|
||||
shape["shape_policy_version"] = MINIMAX_H3_SHAPE_POLICY_VERSION
|
||||
shape["base_short_edge"] = base_short_edge
|
||||
shape["size_mode"] = "deferred"
|
||||
return shape
|
||||
aspect_ratio = auto_aspect_ratio
|
||||
shape["geometry_source"] = auto_geometry_source or "policy_default"
|
||||
ar_w, ar_h = _parse_aspect_ratio(aspect_ratio)
|
||||
shape.update(
|
||||
minimax_h3_resolve_spatial_shape(
|
||||
width=ar_w,
|
||||
height=ar_h,
|
||||
base_short_edge=base_short_edge,
|
||||
)
|
||||
)
|
||||
return shape
|
||||
|
||||
|
||||
def minimax_h3_resolve_plan(canonical: Mapping[str, Any]) -> MiniMaxH3ResolvedPlan:
|
||||
"""Canonical request (already validated) -> ResolvedPlan."""
|
||||
if not isinstance(canonical, Mapping):
|
||||
raise ValueError("canonical request must be a mapping")
|
||||
allowed_keys = {
|
||||
"schema",
|
||||
"task",
|
||||
"prompt",
|
||||
"conditions",
|
||||
"target",
|
||||
"seed",
|
||||
"flow_shift",
|
||||
"audio_flow_shift",
|
||||
}
|
||||
unknown = set(canonical) - allowed_keys
|
||||
if unknown:
|
||||
raise ValueError(f"canonical request has unknown fields: {sorted(unknown)}")
|
||||
for key in ("schema", "task", "prompt", "conditions", "target"):
|
||||
if key not in canonical:
|
||||
raise ValueError(f"canonical request missing {key!r}")
|
||||
profile = minimax_h3_task_profile(str(canonical["task"]))
|
||||
if profile.task == "fl2va":
|
||||
conditions = canonical["conditions"]
|
||||
signatures = (
|
||||
[
|
||||
(
|
||||
condition.get("type"),
|
||||
condition.get("role"),
|
||||
condition.get("frame_index"),
|
||||
)
|
||||
for condition in conditions
|
||||
]
|
||||
if isinstance(conditions, (list, tuple))
|
||||
and all(isinstance(condition, Mapping) for condition in conditions)
|
||||
else []
|
||||
)
|
||||
frame_signature = tuple(signature[2] for signature in signatures)
|
||||
if (
|
||||
not signatures
|
||||
or any(signature[:2] != ("image", "keyframe") for signature in signatures)
|
||||
or frame_signature not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES
|
||||
):
|
||||
raise ValueError(
|
||||
"fl2va ResolvedPlan requires one or two ordered image/keyframe "
|
||||
"conditions with frame_index [0], [-1], or [0, -1], got "
|
||||
f"{signatures!r}"
|
||||
)
|
||||
shape = _resolve_shape(
|
||||
canonical["target"],
|
||||
geometry_source=profile.geometry_source,
|
||||
auto_aspect_ratio=profile.auto_aspect_ratio,
|
||||
auto_geometry_source=profile.auto_geometry_source,
|
||||
)
|
||||
|
||||
materials: list[MiniMaxH3MaterialPlanItem] = []
|
||||
visual_encode: list[int] = []
|
||||
audio_encode: list[int] = []
|
||||
keyframe_semantic_indices: list[int] = []
|
||||
keyframe_pixel_indices: list[int] = []
|
||||
seen_keyframe_pixel_indices: dict[int, int] = {}
|
||||
for index, cond in enumerate(canonical["conditions"]):
|
||||
rule = profile.rule_for(
|
||||
role=str(cond["role"]), condition_type=str(cond["type"])
|
||||
)
|
||||
frame_index = cond.get("frame_index")
|
||||
resolved_frame_index = None
|
||||
if rule.requires_frame_index:
|
||||
if frame_index is None:
|
||||
raise ValueError(f"conditions[{index}].frame_index is required")
|
||||
semantic_frame_index = int(frame_index)
|
||||
frame_count = int(shape["frame_count"])
|
||||
if semantic_frame_index == -1:
|
||||
resolved_frame_index = frame_count - 1
|
||||
elif 0 <= semantic_frame_index < frame_count:
|
||||
resolved_frame_index = semantic_frame_index
|
||||
else:
|
||||
raise ValueError(
|
||||
f"conditions[{index}].frame_index must be -1 or in "
|
||||
f"[0, {frame_count}) after 17n+5 frame alignment, got "
|
||||
f"{semantic_frame_index}"
|
||||
)
|
||||
previous = seen_keyframe_pixel_indices.get(resolved_frame_index)
|
||||
if previous is not None:
|
||||
raise ValueError(
|
||||
f"conditions[{index}].frame_index resolves to "
|
||||
f"{resolved_frame_index}, already bound by "
|
||||
f"conditions[{previous}]"
|
||||
)
|
||||
seen_keyframe_pixel_indices[resolved_frame_index] = index
|
||||
keyframe_semantic_indices.append(semantic_frame_index)
|
||||
keyframe_pixel_indices.append(resolved_frame_index)
|
||||
materials.append(
|
||||
MiniMaxH3MaterialPlanItem(
|
||||
condition_index=index,
|
||||
role=str(cond["role"]),
|
||||
condition_type=str(cond["type"]),
|
||||
uri=str(cond["uri"]),
|
||||
material_chain=rule.material_chain,
|
||||
frame_index=frame_index,
|
||||
resolved_frame_index=resolved_frame_index,
|
||||
start_time_seconds=float(cond.get("start_time_seconds", 0.0)),
|
||||
)
|
||||
)
|
||||
if rule.visual_tokenizer_encode:
|
||||
visual_encode.append(index)
|
||||
if rule.audio_tokenizer_encode:
|
||||
audio_encode.append(index)
|
||||
|
||||
encoders = {
|
||||
"qwen": {
|
||||
"prompt": canonical["prompt"],
|
||||
"ordered_condition_indices": list(range(len(canonical["conditions"]))),
|
||||
},
|
||||
"visual": visual_encode,
|
||||
"audio": audio_encode,
|
||||
}
|
||||
|
||||
condition_mask: dict[str, Any] = {}
|
||||
if keyframe_pixel_indices:
|
||||
condition_mask = {
|
||||
# Both arrays are request-ordered. Semantic indices feed Qwen and
|
||||
# the RoPE rule; resolved
|
||||
# indices are concrete output frames.
|
||||
"semantic_frame_indices": keyframe_semantic_indices,
|
||||
"pixel_frame_indices": keyframe_pixel_indices,
|
||||
}
|
||||
|
||||
return MiniMaxH3ResolvedPlan(
|
||||
task=profile.task,
|
||||
prompt=str(canonical["prompt"]),
|
||||
seed=canonical.get("seed"),
|
||||
materials=tuple(materials),
|
||||
encoders=encoders,
|
||||
branches=profile.branches,
|
||||
default_flow_shift=float(profile.default_flow_shift),
|
||||
default_audio_flow_shift=float(profile.default_audio_flow_shift),
|
||||
flow_shift=(
|
||||
float(canonical["flow_shift"])
|
||||
if canonical.get("flow_shift") is not None
|
||||
else None
|
||||
),
|
||||
audio_flow_shift=(
|
||||
float(canonical["audio_flow_shift"])
|
||||
if canonical.get("audio_flow_shift") is not None
|
||||
else None
|
||||
),
|
||||
shape=shape,
|
||||
condition_mask=condition_mask,
|
||||
)
|
||||
|
||||
|
||||
MINIMAX_H3_CANONICAL_REQUEST_EXTRA_KEY = "minimax_h3_canonical_request"
|
||||
MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY = "minimax_h3_resolved_plan"
|
||||
|
||||
|
||||
def minimax_h3_plan_from_batch(batch: Any) -> MiniMaxH3ResolvedPlan | None:
|
||||
"""Resolve (once) and cache the plan for a Req carrying a canonical request.
|
||||
|
||||
Returns None when the request predates the canonical schema (such
|
||||
requests keep their existing behavior).
|
||||
"""
|
||||
extra = getattr(batch, "extra", None)
|
||||
if not isinstance(extra, Mapping):
|
||||
return None
|
||||
cached = extra.get(MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY)
|
||||
if cached is not None:
|
||||
if not isinstance(cached, MiniMaxH3ResolvedPlan):
|
||||
raise ValueError(
|
||||
f"batch.extra[{MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY!r}] must be a "
|
||||
"MiniMaxH3ResolvedPlan"
|
||||
)
|
||||
canonical = extra.get(MINIMAX_H3_CANONICAL_REQUEST_EXTRA_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if canonical is None:
|
||||
return None
|
||||
plan = minimax_h3_resolve_plan(canonical)
|
||||
if isinstance(extra, dict):
|
||||
extra[MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY] = plan
|
||||
return plan
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MINIMAX_H3_BASE_SHORT_EDGE",
|
||||
"MINIMAX_H3_CANVAS_MULTIPLE",
|
||||
"MINIMAX_H3_CANONICAL_REQUEST_EXTRA_KEY",
|
||||
"MINIMAX_H3_MAX_PIXELS",
|
||||
"MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY",
|
||||
"MiniMaxH3ResolvedPlan",
|
||||
"minimax_h3_plan_from_batch",
|
||||
"minimax_h3_resolve_plan",
|
||||
"minimax_h3_resolve_spatial_shape",
|
||||
]
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pipeline lifecycle stages for the native MiniMax H3 implementation."""
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
|
||||
ComponentUse,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.condition_encoding import (
|
||||
ConditionEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class MiniMaxH3AudioEncodingStage(ConditionEncodingStage):
|
||||
deduplicated_extra_output_keys = (MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY,)
|
||||
|
||||
def __init__(self, audio_vae, vae_arch_config) -> None:
|
||||
super().__init__()
|
||||
self.audio_vae = audio_vae
|
||||
self.vae_arch_config = vae_arch_config
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.ENCODER
|
||||
|
||||
def component_uses(
|
||||
self, server_args: ServerArgs, stage_name: str | None = None
|
||||
) -> list[ComponentUse]:
|
||||
stage_name = self._component_stage_name(stage_name)
|
||||
return [ComponentUse(stage_name, "audio_vae")]
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args: ServerArgs):
|
||||
parent_request_id = batch.extra.get("parent_request_id")
|
||||
return (
|
||||
("expanded_outputs", parent_request_id)
|
||||
if parent_request_id is not None
|
||||
else id(batch)
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_cleanup_temp_dirs,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._forward(batch, server_args)
|
||||
finally:
|
||||
# Audio encoding is the final material consumer in the MiniMax H3
|
||||
# encoder pipeline, including requests with no routed audio.
|
||||
minimax_h3_cleanup_temp_dirs(batch, owners=("material",))
|
||||
|
||||
def _forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is not None:
|
||||
routed = plan.encoders.get("audio")
|
||||
if not routed:
|
||||
return batch
|
||||
self._encode_references_from_plan(batch, plan, routed)
|
||||
return batch
|
||||
if (
|
||||
batch.sampling_params is not None
|
||||
and batch.sampling_params.audio_path is not None
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"MiniMaxH3AudioEncodingStage direct audio tokenizer encode "
|
||||
"requires a canonical minimax_h3 request (resolved plan); "
|
||||
"legacy audio_path-only requests are unsupported."
|
||||
)
|
||||
return batch
|
||||
|
||||
def _encode_references_from_plan(self, batch: Req, plan, routed) -> None:
|
||||
"""Direct reference-audio encode: audio VAE posterior mean ->
|
||||
normalized channel-major rows in batch.extra."""
|
||||
routed_set = set(routed)
|
||||
routed_materials = [
|
||||
material
|
||||
for material in plan.materials
|
||||
if material.condition_index in routed_set
|
||||
]
|
||||
from .replica_broadcast import (
|
||||
minimax_h3_replica_broadcast_error,
|
||||
minimax_h3_replica_broadcast_extra,
|
||||
minimax_h3_replica_ctx,
|
||||
)
|
||||
|
||||
_, replica_rank = minimax_h3_replica_ctx()
|
||||
owner_exception = None
|
||||
owner_error = None
|
||||
if (
|
||||
replica_rank == 0
|
||||
and MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY not in batch.extra
|
||||
):
|
||||
try:
|
||||
with self.use_declared_component(
|
||||
component_name="audio_vae",
|
||||
module=self.audio_vae,
|
||||
) as audio_vae:
|
||||
assert audio_vae is not None
|
||||
self.audio_vae = audio_vae
|
||||
batch.extra[MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY] = (
|
||||
self._encode_reference_payload(
|
||||
batch,
|
||||
plan,
|
||||
routed_materials,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
owner_exception = exc
|
||||
owner_error = f"{type(exc).__name__}: {exc}"
|
||||
owner_error = minimax_h3_replica_broadcast_error(owner_error)
|
||||
if owner_error is not None:
|
||||
if owner_exception is not None:
|
||||
raise owner_exception
|
||||
raise RuntimeError(
|
||||
f"MiniMax H3 audio encode failed on rank 0: {owner_error}"
|
||||
)
|
||||
minimax_h3_replica_broadcast_extra(
|
||||
batch, MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY
|
||||
)
|
||||
|
||||
def _encode_reference_payload(self, batch: Req, plan, materials) -> dict:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_localize_material_uri,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.prequeue import (
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.reference_encoding import (
|
||||
_AudioVAEDeterminismContext,
|
||||
minimax_h3_encode_reference_audio_rows,
|
||||
)
|
||||
|
||||
if not materials:
|
||||
raise ValueError("ref2va audio routing selected no reference materials")
|
||||
entries = []
|
||||
max_duration_seconds = (
|
||||
float(plan.shape["frame_count"]) / float(plan.shape["fps"])
|
||||
if plan.shape.get("frame_count") is not None
|
||||
and plan.shape.get("fps") is not None
|
||||
else None
|
||||
)
|
||||
# One determinism-flag toggle for the whole routed set, not one per
|
||||
# material: _AudioVAEDeterminismContext is reentrant, so each
|
||||
# material's own nested context (inside
|
||||
# minimax_h3_encode_reference_audio_rows) becomes a no-op depth
|
||||
# increment/decrement under this outer scope.
|
||||
with _AudioVAEDeterminismContext():
|
||||
for material in materials:
|
||||
audio_path = minimax_h3_localize_material_uri(
|
||||
batch,
|
||||
material.uri,
|
||||
condition_type=material.condition_type,
|
||||
condition_index=int(material.condition_index),
|
||||
)
|
||||
material_chain = str(material.material_chain)
|
||||
source_facts = batch.extra.get(
|
||||
MINIMAX_H3_PROBE_FACTS_EXTRA_KEY, {}
|
||||
).get(int(material.condition_index))
|
||||
if not isinstance(source_facts, dict):
|
||||
raise ValueError(
|
||||
"reference-audio encoding requires cached pre-queue probe "
|
||||
f"facts for conditions[{int(material.condition_index)}]"
|
||||
)
|
||||
input_has_audio = bool(source_facts.get("has_audio", True))
|
||||
if material_chain == "video.reference_preserve" and not input_has_audio:
|
||||
# Keep the visual reference block in request order while
|
||||
# representing the absent soundtrack as a zero-length
|
||||
# audio condition.
|
||||
out = {
|
||||
"rows": torch.empty((0, 32), dtype=torch.float32),
|
||||
"ref_audio_t": 0,
|
||||
"duration_seconds": 0.0,
|
||||
}
|
||||
else:
|
||||
out = minimax_h3_encode_reference_audio_rows(
|
||||
self.audio_vae,
|
||||
audio_path,
|
||||
self.vae_arch_config,
|
||||
material_chain=material_chain,
|
||||
max_duration_seconds=max_duration_seconds,
|
||||
start_time_seconds=float(material.start_time_seconds),
|
||||
source_sample_rate=(
|
||||
int(source_facts["audio_sample_rate"])
|
||||
if material_chain == "audio"
|
||||
else None
|
||||
),
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
**out,
|
||||
"condition_index": int(material.condition_index),
|
||||
"material_chain": material_chain,
|
||||
}
|
||||
)
|
||||
payload = dict(entries[0]) if len(entries) == 1 else {}
|
||||
payload["audios"] = entries
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3AudioEncodingStage"]
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from collections.abc import Mapping
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.distributed import (
|
||||
get_world_group,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
|
||||
ComponentUse,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch, Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import (
|
||||
StageParallelismType,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.decoding import DecodingStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.precision import (
|
||||
autocast_enabled,
|
||||
resolve_decode_precision,
|
||||
resolve_precision,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.torch_compile import (
|
||||
ActiveTargetCompiledCallable,
|
||||
)
|
||||
|
||||
|
||||
def _required_tensor(value, path: str) -> torch.Tensor:
|
||||
if not isinstance(value, torch.Tensor):
|
||||
raise ValueError(f"{path} must be a torch.Tensor")
|
||||
return value
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _cached_decode_mean_std(
|
||||
mean_values: tuple[float, ...],
|
||||
std_values: tuple[float, ...],
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Device/dtype-keyed mean/std tensors, built once per distinct combination.
|
||||
|
||||
mean_values/std_values come from the loaded arch_config and are fixed
|
||||
for the process lifetime, so the same (values, device, dtype) always
|
||||
reconstructs an identical tensor; cache it instead of rebuilding it on
|
||||
every decode call.
|
||||
"""
|
||||
mean = torch.as_tensor(mean_values, device=device, dtype=dtype)
|
||||
std = torch.as_tensor(std_values, device=device, dtype=dtype)
|
||||
return mean, std
|
||||
|
||||
|
||||
def _reverse_normalize_latents_(
|
||||
latents: torch.Tensor,
|
||||
*,
|
||||
mean_values,
|
||||
std_values,
|
||||
name: str,
|
||||
) -> torch.Tensor:
|
||||
mean, std = _cached_decode_mean_std(
|
||||
tuple(mean_values), tuple(std_values), latents.device, latents.dtype
|
||||
)
|
||||
if mean.ndim != 1:
|
||||
raise ValueError(f"{name}.latents_mean must be 1-D, got {tuple(mean.shape)}")
|
||||
if std.ndim != 1:
|
||||
raise ValueError(f"{name}.latents_std must be 1-D, got {tuple(std.shape)}")
|
||||
if mean.shape != std.shape:
|
||||
raise ValueError(
|
||||
f"{name} latent normalization shape mismatch: "
|
||||
f"mean={tuple(mean.shape)} std={tuple(std.shape)}"
|
||||
)
|
||||
if latents.ndim < 2:
|
||||
raise ValueError(f"{name} latents must have a channel dimension")
|
||||
if int(latents.shape[1]) != int(mean.shape[0]):
|
||||
raise ValueError(
|
||||
f"{name} latent normalization channel mismatch: "
|
||||
f"latents.shape[1]={int(latents.shape[1])} mean_len={int(mean.shape[0])}"
|
||||
)
|
||||
view_shape = [1] * latents.ndim
|
||||
view_shape[1] = int(mean.shape[0])
|
||||
return latents.mul_(std.view(*view_shape)).add_(mean.view(*view_shape))
|
||||
|
||||
|
||||
def _crop_to_target_canvas(batch: Req, frames: torch.Tensor) -> torch.Tensor:
|
||||
"""Crop decoded frames [B,C,T,H,W] back to the target canvas.
|
||||
|
||||
The visual VAE pads the latent grid to its tile multiples (padding lands
|
||||
at the bottom/right), so a non-tile-aligned geometry decodes larger than the
|
||||
requested canvas (e.g. 1344x768 for a 1280x704 target). Target dims come
|
||||
from the direct-mode denoise state (latent_h/w * 16); requests without
|
||||
that state keep the raw decode.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
)
|
||||
|
||||
state = batch.extra.get(MINIMAX_H3_DENOISE_STATE_EXTRA_KEY)
|
||||
if state is None:
|
||||
return frames
|
||||
target_h = int(state["latent_h"]) * 16
|
||||
target_w = int(state["latent_w"]) * 16
|
||||
h, w = int(frames.shape[-2]), int(frames.shape[-1])
|
||||
if h < target_h or w < target_w:
|
||||
raise ValueError(
|
||||
f"decoded frames {h}x{w} smaller than target canvas {target_h}x{target_w}"
|
||||
)
|
||||
if h == target_h and w == target_w:
|
||||
return frames
|
||||
return frames[..., :target_h, :target_w]
|
||||
|
||||
|
||||
def _canonical_visual_video_frames(
|
||||
frames: torch.Tensor, *, batch_size: int
|
||||
) -> torch.Tensor:
|
||||
if frames.ndim == 4:
|
||||
if int(frames.shape[0]) % batch_size != 0:
|
||||
raise ValueError(
|
||||
f"Decoded visual video shape {tuple(frames.shape)} is incompatible "
|
||||
f"with batch_size={batch_size}"
|
||||
)
|
||||
frames = frames.reshape(
|
||||
batch_size, int(frames.shape[0]) // batch_size, *frames.shape[1:]
|
||||
)
|
||||
frames = frames.transpose(1, 2)
|
||||
elif frames.ndim == 5:
|
||||
if int(frames.shape[0]) != batch_size:
|
||||
raise ValueError(
|
||||
f"Decoded visual video batch mismatch: frames.shape[0]={int(frames.shape[0])} "
|
||||
f"batch_size={batch_size}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Decoded visual video shape {tuple(frames.shape)} is not supported"
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def _canonical_output_audio_waveform(
|
||||
audio_waveform: torch.Tensor, *, batch_size: int
|
||||
) -> torch.Tensor:
|
||||
"""Project audio-VAE-native ``[C, 1, L]`` audio to output ``[1, C, L]``.
|
||||
|
||||
The audio VAE treats stereo channels as its decoder batch and returns
|
||||
``[2, 1, samples]`` for MiniMax H3's one generated sample. The generic output
|
||||
path instead selects generated samples along dimension zero. Keep the audio VAE
|
||||
tensor unchanged for decoder artifacts, then make the singleton generated-
|
||||
sample dimension explicit only at the ``OutputBatch`` boundary.
|
||||
"""
|
||||
if audio_waveform.ndim != 3:
|
||||
raise ValueError(
|
||||
"Decoded audio VAE waveform must be [C, 1, L], got "
|
||||
f"{tuple(audio_waveform.shape)}"
|
||||
)
|
||||
if batch_size != 1:
|
||||
raise ValueError(
|
||||
"MiniMax H3 audio VAE output only supports one generated sample, "
|
||||
f"got visual batch_size={batch_size}"
|
||||
)
|
||||
if int(audio_waveform.shape[1]) != 1:
|
||||
raise ValueError(
|
||||
"Decoded audio VAE waveform must have shape [C, 1, L], got "
|
||||
f"{tuple(audio_waveform.shape)}"
|
||||
)
|
||||
return audio_waveform.permute(1, 0, 2).contiguous()
|
||||
|
||||
|
||||
_MINIMAX_H3_DECODER_TASKS = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
_MINIMAX_H3_CANONICAL_REQUEST_EXTRA_KEY = "minimax_h3_canonical_request"
|
||||
_MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY = "minimax_h3_resolved_plan"
|
||||
|
||||
|
||||
def _minimax_h3_decoder_task(batch: Req) -> str | None:
|
||||
"""Return the validated request task used for output-decoder routing.
|
||||
|
||||
Debug requests have no canonical task and retain the
|
||||
generic decoder.
|
||||
"""
|
||||
|
||||
extra = getattr(batch, "extra", None)
|
||||
if not isinstance(extra, Mapping):
|
||||
return None
|
||||
canonical = extra.get(_MINIMAX_H3_CANONICAL_REQUEST_EXTRA_KEY)
|
||||
if canonical is not None and not isinstance(canonical, Mapping):
|
||||
raise ValueError("minimax_h3_canonical_request must be a mapping")
|
||||
canonical_task = canonical.get("task") if isinstance(canonical, Mapping) else None
|
||||
resolved = extra.get(_MINIMAX_H3_RESOLVED_PLAN_EXTRA_KEY)
|
||||
resolved_task = getattr(resolved, "task", None) if resolved is not None else None
|
||||
if canonical_task is not None and resolved_task is not None:
|
||||
if str(canonical_task) != str(resolved_task):
|
||||
raise ValueError(
|
||||
"MiniMax H3 decoder task mismatch between canonical request and "
|
||||
"resolved plan"
|
||||
)
|
||||
task_value = resolved_task if resolved_task is not None else canonical_task
|
||||
if task_value is None:
|
||||
return None
|
||||
if not isinstance(task_value, str) or task_value not in _MINIMAX_H3_DECODER_TASKS:
|
||||
raise ValueError(f"unsupported MiniMax H3 decoder task {task_value!r}")
|
||||
return task_value
|
||||
|
||||
|
||||
class MiniMaxH3DecodingStage(DecodingStage):
|
||||
def __init__(self, video_vae, audio_vae) -> None:
|
||||
super().__init__(vae=video_vae, component_name="video_vae")
|
||||
self.video_vae = video_vae
|
||||
self.audio_vae = audio_vae
|
||||
self._compiled_audio_vae_decode = ActiveTargetCompiledCallable()
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.DECODER
|
||||
|
||||
@property
|
||||
def parallelism_type(self) -> StageParallelismType:
|
||||
# Every decode-group rank owns a subset of visual VAE tiles. The GPU
|
||||
# worker only materializes/saves the final OutputBatch on world rank 0.
|
||||
return StageParallelismType.REPLICATED
|
||||
|
||||
def component_uses(
|
||||
self, server_args: ServerArgs, stage_name: str | None = None
|
||||
) -> list[ComponentUse]:
|
||||
stage_name = self._component_stage_name(stage_name)
|
||||
video_vae_dtype = resolve_precision(
|
||||
server_args, "video_vae", precision_attr="vae_precision"
|
||||
)
|
||||
audio_vae_dtype = resolve_precision(
|
||||
server_args, "audio_vae", precision_attr="audio_vae_precision"
|
||||
)
|
||||
uses = [
|
||||
ComponentUse(stage_name, "video_vae", target_dtype=video_vae_dtype),
|
||||
]
|
||||
uses.append(ComponentUse(stage_name, "audio_vae", target_dtype=audio_vae_dtype))
|
||||
return uses
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
|
||||
result.add_check(
|
||||
"audio_latents",
|
||||
batch.audio_latents,
|
||||
[V.is_tensor, V.with_dims(3)],
|
||||
)
|
||||
return result
|
||||
|
||||
def verify_output(
|
||||
self, batch: OutputBatch, server_args: ServerArgs
|
||||
) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("output", batch.output, [V.is_tensor, V.with_dims(5)])
|
||||
result.add_check("audio", batch.audio, [V.is_tensor, V.with_dims(3)])
|
||||
result.add_check("audio_sample_rate", batch.audio_sample_rate, V.positive_int)
|
||||
return result
|
||||
|
||||
def _decode_audio(
|
||||
self,
|
||||
audio_latent: torch.Tensor,
|
||||
server_args: ServerArgs,
|
||||
) -> dict:
|
||||
with self.use_declared_component(
|
||||
component_name="audio_vae",
|
||||
module=self.audio_vae,
|
||||
) as audio_vae:
|
||||
assert audio_vae is not None
|
||||
self.audio_vae = audio_vae
|
||||
if audio_vae.training:
|
||||
audio_vae.eval()
|
||||
audio_arch_config = server_args.pipeline_config.audio_vae_config.arch_config
|
||||
audio_decode_latent = _reverse_normalize_latents_(
|
||||
audio_latent,
|
||||
mean_values=audio_arch_config.latents_mean,
|
||||
std_values=audio_arch_config.latents_std,
|
||||
name="audio_vae",
|
||||
)
|
||||
audio_vae_dtype = resolve_precision(
|
||||
server_args, "audio_vae", precision_attr="audio_vae_precision"
|
||||
)
|
||||
audio_autocast_enabled = (
|
||||
audio_latent.device.type == "cuda"
|
||||
and autocast_enabled(audio_vae_dtype, server_args.disable_autocast)
|
||||
)
|
||||
with torch.autocast(
|
||||
device_type=audio_latent.device.type,
|
||||
dtype=audio_vae_dtype,
|
||||
enabled=audio_autocast_enabled,
|
||||
):
|
||||
audio_decode = self._get_vae_decode_fn(
|
||||
audio_vae,
|
||||
server_args,
|
||||
decode_fn=audio_vae.decode,
|
||||
compiled_callable=self._compiled_audio_vae_decode,
|
||||
)
|
||||
waveform = _required_tensor(
|
||||
audio_decode(audio_decode_latent), "audio_vae.decode"
|
||||
)
|
||||
return {
|
||||
"waveform": waveform,
|
||||
"sample_rate": int(audio_vae.sample_rate),
|
||||
}
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> OutputBatch:
|
||||
_minimax_h3_decoder_task(batch)
|
||||
visual_latent = _required_tensor(batch.latents, "batch.latents")
|
||||
audio_latent = _required_tensor(batch.audio_latents, "batch.audio_latents")
|
||||
if visual_latent.ndim != 5:
|
||||
raise ValueError("batch.latents must be [B, C, T, H, W]")
|
||||
if audio_latent.ndim != 3:
|
||||
raise ValueError(
|
||||
"batch.audio_latents must be [audio_channel, latent_dim, T]"
|
||||
)
|
||||
|
||||
if self.video_vae is None:
|
||||
raise RuntimeError("MiniMax H3 tasks require the video_vae output decoder")
|
||||
with self.use_declared_component(
|
||||
component_name="video_vae",
|
||||
module=self.video_vae,
|
||||
) as selected_video_vae:
|
||||
if selected_video_vae is None:
|
||||
raise RuntimeError("video_vae became unavailable during decode")
|
||||
self.video_vae = selected_video_vae
|
||||
if selected_video_vae.training:
|
||||
selected_video_vae.eval()
|
||||
visual_arch_config = server_args.pipeline_config.vae_config.arch_config
|
||||
visual_decode_latent = _reverse_normalize_latents_(
|
||||
visual_latent,
|
||||
mean_values=visual_arch_config.latents_mean,
|
||||
std_values=visual_arch_config.latents_std,
|
||||
name="video_vae",
|
||||
)
|
||||
video_vae_dtype = resolve_decode_precision(server_args, "video_vae")
|
||||
visual_autocast_enabled = (
|
||||
visual_latent.device.type == "cuda"
|
||||
and autocast_enabled(video_vae_dtype, server_args.disable_autocast)
|
||||
)
|
||||
if visual_autocast_enabled:
|
||||
selected_video_vae.prepare_decoder_autocast_weights(video_vae_dtype)
|
||||
with torch.autocast(
|
||||
device_type=visual_latent.device.type,
|
||||
dtype=video_vae_dtype,
|
||||
enabled=visual_autocast_enabled,
|
||||
):
|
||||
video_decode = self._get_vae_decode_fn(
|
||||
selected_video_vae,
|
||||
server_args,
|
||||
decode_fn=selected_video_vae.decode_base,
|
||||
)
|
||||
visual_frames = video_decode(visual_decode_latent)
|
||||
visual_frames = selected_video_vae.processor.revert_tensor(
|
||||
visual_frames
|
||||
)
|
||||
visual_frames = _required_tensor(
|
||||
visual_frames,
|
||||
"video_vae.processor.revert_tensor",
|
||||
)
|
||||
visual_frames = _canonical_visual_video_frames(
|
||||
visual_frames, batch_size=int(visual_latent.shape[0])
|
||||
)
|
||||
visual_frames = _crop_to_target_canvas(batch, visual_frames)
|
||||
if (
|
||||
visual_frames.dtype != torch.float32
|
||||
or not visual_frames.is_contiguous()
|
||||
):
|
||||
canonical_frames = torch.empty_like(
|
||||
visual_frames,
|
||||
dtype=torch.float32,
|
||||
memory_format=torch.contiguous_format,
|
||||
)
|
||||
canonical_frames.copy_(visual_frames)
|
||||
visual_frames = canonical_frames
|
||||
|
||||
# DP is currently rejected by ServerArgs validation, so the world group
|
||||
# is one request replica (TP/CFG/SP ranks), not a collection of
|
||||
# independent requests. Decode the non-sharded audio VAE once per
|
||||
# request and distribute its output to the ranks that decoded video.
|
||||
world_group = get_world_group() if model_parallel_is_initialized() else None
|
||||
is_audio_owner = world_group is None or world_group.rank_in_group == 0
|
||||
owner_exception = None
|
||||
owner_error = None
|
||||
audio_payload = None
|
||||
if is_audio_owner:
|
||||
try:
|
||||
audio_payload = self._decode_audio(audio_latent, server_args)
|
||||
except Exception as exc:
|
||||
owner_exception = exc
|
||||
owner_error = f"{type(exc).__name__}: {exc}"
|
||||
if world_group is not None:
|
||||
owner_error = world_group.broadcast_object(owner_error, src=0)
|
||||
if owner_error is not None:
|
||||
if owner_exception is not None:
|
||||
raise owner_exception
|
||||
raise RuntimeError(
|
||||
f"MiniMax H3 audio decode failed on rank 0: {owner_error}"
|
||||
)
|
||||
if world_group is not None:
|
||||
audio_payload = world_group.broadcast_tensor_dict(audio_payload, src=0)
|
||||
if not isinstance(audio_payload, dict):
|
||||
raise RuntimeError("MiniMax H3 audio decode produced no output payload")
|
||||
audio_waveform = _required_tensor(
|
||||
audio_payload.get("waveform"), "audio_vae.decode"
|
||||
)
|
||||
audio_sample_rate = int(audio_payload["sample_rate"])
|
||||
|
||||
visual_frames = server_args.pipeline_config.post_decoding(
|
||||
visual_frames, server_args
|
||||
)
|
||||
output_audio_waveform = _canonical_output_audio_waveform(
|
||||
audio_waveform, batch_size=int(visual_frames.shape[0])
|
||||
)
|
||||
return OutputBatch(
|
||||
output=visual_frames,
|
||||
audio=output_audio_waveform,
|
||||
audio_sample_rate=audio_sample_rate,
|
||||
trajectory_timesteps=batch.trajectory_timesteps,
|
||||
trajectory_latents=batch.trajectory_latents,
|
||||
rollout_trajectory_data=batch.rollout_trajectory_data,
|
||||
trajectory_decoded=None,
|
||||
metrics=batch.metrics,
|
||||
noise_pred=None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3DecodingStage",
|
||||
]
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax H3 denoise sink for packed-token DiT stepping, CFG-distilled
|
||||
single-branch execution, and payload validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.cache.cache_dit_integration import (
|
||||
CacheDitConfig,
|
||||
disable_cache_on_transformer,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_resident_strategies import (
|
||||
is_fsdp_managed_module,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.denoising import (
|
||||
DenoisingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_QUALITY_PROFILES,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.runtime.utils.nvtx_pytorch_hooks import maybe_nvtx_range
|
||||
from sglang.multimodal_gen.runtime.utils.perf_logger import StageProfiler
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_REF2VA_VIDEO_CHAINS = {
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
}
|
||||
|
||||
|
||||
def minimax_h3_condition_noise_aug(sampling: Any) -> tuple[float, float]:
|
||||
"""Resolve condition timesteps using the model defaults."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.denoise_loop import (
|
||||
MINIMAX_H3_AUDIO_REF_COND_TIMESTEP,
|
||||
MINIMAX_H3_IMGVID_COND_TIMESTEP,
|
||||
)
|
||||
|
||||
imgvid_noise_aug = getattr(
|
||||
sampling,
|
||||
"imgvid_cond_noise_aug_for_inference",
|
||||
None,
|
||||
)
|
||||
if imgvid_noise_aug is None:
|
||||
# The model default uses imgvid noise aug 0.999. A request selecting
|
||||
# imgvid noise aug 1.0 still overrides this.
|
||||
imgvid_noise_aug = MINIMAX_H3_IMGVID_COND_TIMESTEP
|
||||
audio_noise_aug = getattr(
|
||||
sampling,
|
||||
"audio_cond_noise_aug_for_inference",
|
||||
None,
|
||||
)
|
||||
if audio_noise_aug is None:
|
||||
audio_noise_aug = MINIMAX_H3_AUDIO_REF_COND_TIMESTEP
|
||||
return float(imgvid_noise_aug), float(audio_noise_aug)
|
||||
|
||||
|
||||
def _validate_fl2va_keyframe_payload(plan: Any, keyframe: Any) -> None:
|
||||
"""Reject stale/middle/reordered keyframe payloads at the DiT sink."""
|
||||
|
||||
task = None if plan is None else str(plan.task)
|
||||
if task != "fl2va":
|
||||
if keyframe is not None:
|
||||
raise ValueError(
|
||||
"keyframe condition rows are only valid for plan.task='fl2va'"
|
||||
)
|
||||
return
|
||||
if not isinstance(keyframe, Mapping):
|
||||
raise ValueError("fl2va denoising requires encoded keyframe condition rows")
|
||||
|
||||
semantic_indices = tuple(keyframe.get("semantic_frame_indices") or ())
|
||||
if semantic_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"fl2va denoising requires semantic_frame_indices in "
|
||||
f"{MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, "
|
||||
f"got {semantic_indices!r}"
|
||||
)
|
||||
frame_count = keyframe.get("frame_count")
|
||||
if isinstance(frame_count, bool) or not isinstance(frame_count, int):
|
||||
raise ValueError("fl2va keyframe payload requires an integer frame_count")
|
||||
if frame_count <= 1:
|
||||
raise ValueError("fl2va keyframe payload frame_count must be greater than one")
|
||||
pixel_indices = keyframe.get("pixel_frame_indices")
|
||||
expected_pixel_indices = [
|
||||
frame_count - 1 if index == -1 else index for index in semantic_indices
|
||||
]
|
||||
if pixel_indices != expected_pixel_indices:
|
||||
raise ValueError(
|
||||
"fl2va denoising requires pixel_frame_indices resolved from the "
|
||||
"semantic anchors, "
|
||||
f"got {pixel_indices!r} for frame_count={frame_count}"
|
||||
)
|
||||
|
||||
entries = keyframe.get("keyframes")
|
||||
if (
|
||||
not isinstance(entries, list)
|
||||
or len(entries) != len(semantic_indices)
|
||||
or any(not isinstance(entry, Mapping) for entry in entries)
|
||||
):
|
||||
raise ValueError(
|
||||
"fl2va denoising requires one encoded keyframe per semantic anchor"
|
||||
)
|
||||
if [entry.get("frame_index") for entry in entries] != list(semantic_indices):
|
||||
raise ValueError("fl2va encoded keyframes must remain in semantic anchor order")
|
||||
if [
|
||||
entry.get("resolved_frame_index") for entry in entries
|
||||
] != expected_pixel_indices:
|
||||
raise ValueError(
|
||||
"fl2va encoded keyframes must carry matching resolved_frame_index values"
|
||||
)
|
||||
|
||||
latent_h = int(keyframe.get("latent_h") or 0)
|
||||
latent_w = int(keyframe.get("latent_w") or 0)
|
||||
rows = keyframe.get("rows")
|
||||
expected_rows = len(semantic_indices) * (latent_h // 2) * (latent_w // 2)
|
||||
if (
|
||||
latent_h <= 0
|
||||
or latent_w <= 0
|
||||
or not isinstance(rows, torch.Tensor)
|
||||
or int(rows.shape[0]) != expected_rows
|
||||
):
|
||||
actual_rows = None if not isinstance(rows, torch.Tensor) else int(rows.shape[0])
|
||||
raise ValueError(
|
||||
"fl2va encoded keyframe rows do not match target-canvas blocks: "
|
||||
f"expected={expected_rows}, actual={actual_rows}"
|
||||
)
|
||||
|
||||
|
||||
def _imgvid_condition_shapes(
|
||||
*,
|
||||
ref2va_blocks: list[dict[str, int | str]] | None,
|
||||
keyframe: Any,
|
||||
is_ref2va: bool,
|
||||
) -> list[tuple[int, int, int]]:
|
||||
"""Return visual-condition ``(T,H,W)`` in packed anchor-row order."""
|
||||
|
||||
if ref2va_blocks is not None:
|
||||
shapes = []
|
||||
for block in ref2va_blocks:
|
||||
kind = str(block["kind"])
|
||||
if kind == "image":
|
||||
shapes.append((1, int(block["latent_h"]), int(block["latent_w"])))
|
||||
elif kind in {"video", "video_audio"}:
|
||||
shapes.append(
|
||||
(
|
||||
int(block["latent_t"]),
|
||||
int(block["latent_h"]),
|
||||
int(block["latent_w"]),
|
||||
)
|
||||
)
|
||||
return shapes
|
||||
|
||||
if is_ref2va:
|
||||
# ref2va always carries a resolved plan, so ordered blocks are
|
||||
# supplied above; reaching here indicates an upstream bug.
|
||||
raise ValueError("ref2va visual-condition shapes require ordered blocks")
|
||||
|
||||
if not isinstance(keyframe, Mapping):
|
||||
return []
|
||||
entries = keyframe.get("keyframes")
|
||||
if isinstance(entries, list) and entries:
|
||||
return [
|
||||
(1, int(entry["latent_h"]), int(entry["latent_w"])) for entry in entries
|
||||
]
|
||||
|
||||
latent_h = int(keyframe["latent_h"])
|
||||
latent_w = int(keyframe["latent_w"])
|
||||
frame_rows = (latent_h // 2) * (latent_w // 2)
|
||||
rows = keyframe["rows"]
|
||||
if frame_rows <= 0 or int(rows.shape[0]) % frame_rows:
|
||||
raise ValueError(
|
||||
"legacy keyframe rows cannot be split into visual-condition frames"
|
||||
)
|
||||
return [(1, latent_h, latent_w)] * (int(rows.shape[0]) // frame_rows)
|
||||
|
||||
|
||||
def _ref2va_payload_entry(
|
||||
payload: Any,
|
||||
*,
|
||||
list_key: str,
|
||||
condition_index: int,
|
||||
path: str,
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ValueError(f"{path} is required for ref2va condition rows")
|
||||
entries = payload.get(list_key)
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if (
|
||||
isinstance(entry, Mapping)
|
||||
and entry.get("condition_index") is not None
|
||||
and int(entry["condition_index"]) == int(condition_index)
|
||||
):
|
||||
return entry
|
||||
if len(entries) == 1 and isinstance(entries[0], Mapping):
|
||||
return entries[0]
|
||||
raise ValueError(
|
||||
f"{path}.{list_key} missing entry for condition_index={condition_index}"
|
||||
)
|
||||
if payload.get("condition_index") is None or int(payload["condition_index"]) == int(
|
||||
condition_index
|
||||
):
|
||||
return payload
|
||||
raise ValueError(
|
||||
f"{path}.{list_key} missing entry for condition_index={condition_index}"
|
||||
)
|
||||
|
||||
|
||||
def _cat_optional(rows: list[torch.Tensor]) -> torch.Tensor | None:
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0] if len(rows) == 1 else torch.cat(rows, dim=0)
|
||||
|
||||
|
||||
def _ref2va_ordered_blocks_and_rows(
|
||||
*,
|
||||
plan: Any,
|
||||
ref_image: Any,
|
||||
ref_audio: Any,
|
||||
ref_video: Any,
|
||||
) -> tuple[list[dict[str, int | str]], torch.Tensor | None, torch.Tensor | None]:
|
||||
blocks: list[dict[str, int | str]] = []
|
||||
visual_rows: list[torch.Tensor] = []
|
||||
audio_rows: list[torch.Tensor] = []
|
||||
for material in plan.materials:
|
||||
chain = str(material.material_chain)
|
||||
condition_index = int(material.condition_index)
|
||||
if chain == "image.reference_preserve":
|
||||
entry = _ref2va_payload_entry(
|
||||
ref_image,
|
||||
list_key="images",
|
||||
condition_index=condition_index,
|
||||
path="batch.extra.minimax_h3_reference_image_rows",
|
||||
)
|
||||
blocks.append(
|
||||
{
|
||||
"kind": "image",
|
||||
"latent_h": int(entry["latent_h"]),
|
||||
"latent_w": int(entry["latent_w"]),
|
||||
}
|
||||
)
|
||||
visual_rows.append(entry["rows"])
|
||||
elif chain == "audio":
|
||||
entry = _ref2va_payload_entry(
|
||||
ref_audio,
|
||||
list_key="audios",
|
||||
condition_index=condition_index,
|
||||
path="batch.extra.minimax_h3_reference_audio_rows",
|
||||
)
|
||||
ref_audio_t = int(entry["ref_audio_t"])
|
||||
blocks.append({"kind": "audio", "ref_audio_t": ref_audio_t})
|
||||
if ref_audio_t > 0:
|
||||
audio_rows.append(entry["rows"])
|
||||
elif chain in _REF2VA_VIDEO_CHAINS:
|
||||
video_entry = _ref2va_payload_entry(
|
||||
ref_video,
|
||||
list_key="videos",
|
||||
condition_index=condition_index,
|
||||
path="batch.extra.minimax_h3_reference_video_rows",
|
||||
)
|
||||
audio_entry = _ref2va_payload_entry(
|
||||
ref_audio,
|
||||
list_key="audios",
|
||||
condition_index=condition_index,
|
||||
path="batch.extra.minimax_h3_reference_audio_rows",
|
||||
)
|
||||
ref_audio_t = int(audio_entry["ref_audio_t"])
|
||||
blocks.append(
|
||||
{
|
||||
"kind": (
|
||||
"video_audio"
|
||||
if chain == "video_audio.reference_preserve"
|
||||
else "video"
|
||||
),
|
||||
"ref_audio_t": ref_audio_t,
|
||||
"latent_t": int(video_entry["latent_t"]),
|
||||
"latent_h": int(video_entry["latent_h"]),
|
||||
"latent_w": int(video_entry["latent_w"]),
|
||||
}
|
||||
)
|
||||
visual_rows.append(video_entry["rows"])
|
||||
if ref_audio_t > 0:
|
||||
audio_rows.append(audio_entry["rows"])
|
||||
else:
|
||||
raise ValueError(f"unsupported ref2va material chain {chain!r}")
|
||||
return blocks, _cat_optional(visual_rows), _cat_optional(audio_rows)
|
||||
|
||||
|
||||
def _resolve_denoise_model(
|
||||
transformer: Any,
|
||||
device: torch.device,
|
||||
*,
|
||||
placement_managed: bool = False,
|
||||
) -> Any:
|
||||
"""Resolve the DiT module and place it for denoise.
|
||||
|
||||
ComponentManager and FSDP own their device placement; move only unmanaged
|
||||
plain modules.
|
||||
"""
|
||||
model = getattr(transformer, "model", transformer)
|
||||
if placement_managed or is_fsdp_managed_module(model):
|
||||
if model.training:
|
||||
model.eval()
|
||||
return model
|
||||
return model.to(device).eval()
|
||||
|
||||
|
||||
def _precompute_refined_prompt_embeds(
|
||||
model: Any,
|
||||
positive: Any,
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> bool:
|
||||
"""Move request-static text refinement out of the denoise hot loop."""
|
||||
refine = getattr(model, "refine_prompt_embeds", None)
|
||||
if not callable(refine):
|
||||
return False
|
||||
|
||||
static_kwargs = positive.static_kwargs
|
||||
prompt_embeds = static_kwargs["prompt_embeds"]
|
||||
refiner_params = static_kwargs["refiner_packed_seq_params"]
|
||||
if isinstance(refiner_params, dict):
|
||||
refiner_cu = refiner_params["cu_seqlens_q"]
|
||||
else:
|
||||
refiner_cu = refiner_params.cu_seqlens_q
|
||||
with torch.inference_mode():
|
||||
refined = refine(
|
||||
prompt_embeds,
|
||||
refiner_cu,
|
||||
device=device,
|
||||
)
|
||||
if not torch.is_tensor(refined):
|
||||
raise TypeError("MiniMax H3 refine_prompt_embeds must return a torch.Tensor")
|
||||
if int(refined.shape[0]) != int(prompt_embeds.shape[0]):
|
||||
raise ValueError(
|
||||
"MiniMax H3 refined prompt row count changed: "
|
||||
f"{int(prompt_embeds.shape[0])} -> {int(refined.shape[0])}"
|
||||
)
|
||||
static_kwargs["prompt_embeds"] = refined
|
||||
static_kwargs["refined_prompt_embeds_length"] = int(refined.shape[0])
|
||||
return True
|
||||
|
||||
|
||||
def _precompute_rope_cache(
|
||||
model: Any,
|
||||
positive: Any,
|
||||
*,
|
||||
device: torch.device,
|
||||
) -> bool:
|
||||
"""Move request-static RoPE construction out of the denoise hot loop."""
|
||||
build = getattr(model, "build_rope_cache", None)
|
||||
if not callable(build):
|
||||
return False
|
||||
static_kwargs = positive.static_kwargs
|
||||
with torch.inference_mode():
|
||||
static_kwargs["rope_cache"] = build(
|
||||
static_kwargs["img_position_ids"],
|
||||
device=device,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class MiniMaxH3DenoisingStage(DenoisingStage):
|
||||
def __init__(self, transformer, pipeline=None) -> None:
|
||||
super().__init__(
|
||||
transformer=transformer,
|
||||
scheduler=None,
|
||||
pipeline=pipeline,
|
||||
)
|
||||
self._minimax_h3_quality_profile = "lossless"
|
||||
self._minimax_h3_cache_mode: str | None = None
|
||||
|
||||
def _owns_compile_warmup_lifecycle(self) -> bool:
|
||||
return True
|
||||
|
||||
def _cache_dit_requested(self) -> bool:
|
||||
return (
|
||||
getattr(self, "_minimax_h3_quality_profile", "lossless") != "lossless"
|
||||
or super()._cache_dit_requested()
|
||||
)
|
||||
|
||||
def _maybe_enable_cache_dit(
|
||||
self, num_inference_steps: int | tuple[int, int], batch: Req
|
||||
) -> None:
|
||||
quality = getattr(batch.sampling_params, "quality", "lossless")
|
||||
if quality not in MINIMAX_H3_QUALITY_PROFILES:
|
||||
raise ValueError(f"unsupported MiniMax-H3 quality profile {quality!r}")
|
||||
explicit_fields = getattr(batch.sampling_params, "_explicit_fields", ())
|
||||
generic_requested = (
|
||||
super()._cache_dit_requested() and "quality" not in explicit_fields
|
||||
)
|
||||
desired_mode = (
|
||||
quality
|
||||
if quality != "lossless"
|
||||
else ("generic" if generic_requested else None)
|
||||
)
|
||||
current_mode = getattr(self, "_minimax_h3_cache_mode", None)
|
||||
self._minimax_h3_quality_profile = quality
|
||||
|
||||
# H3 is monolithic-only, and the scheduler executes one worker batch at
|
||||
# a time. Combined with `quality` in the dynamic-batch signature, this
|
||||
# makes the process-wide hook transition safe at this batch boundary.
|
||||
if self._cache_dit_enabled and current_mode != desired_mode:
|
||||
self.transformer = disable_cache_on_transformer(self.transformer)
|
||||
self._cache_dit_enabled = False
|
||||
self._cached_num_steps = None
|
||||
self._minimax_h3_cache_mode = None
|
||||
|
||||
if desired_mode is None:
|
||||
return
|
||||
super()._maybe_enable_cache_dit(num_inference_steps, batch)
|
||||
if self._cache_dit_enabled:
|
||||
self._minimax_h3_cache_mode = desired_mode
|
||||
|
||||
def _cache_dit_scm_masks(
|
||||
self, primary_num_steps: int, secondary_num_steps: int | None = None
|
||||
) -> tuple[str, str, list[int] | None, list[int] | None]:
|
||||
if getattr(self, "_minimax_h3_quality_profile", "lossless") != "lossless":
|
||||
return "none", "dynamic", None, None
|
||||
return super()._cache_dit_scm_masks(primary_num_steps, secondary_num_steps)
|
||||
|
||||
def _build_cache_dit_config(
|
||||
self,
|
||||
num_inference_steps: int,
|
||||
steps_computation_mask: list[int] | None,
|
||||
scm_policy: str,
|
||||
*,
|
||||
secondary: bool = False,
|
||||
) -> CacheDitConfig:
|
||||
quality = getattr(self, "_minimax_h3_quality_profile", "lossless")
|
||||
profile = MINIMAX_H3_QUALITY_PROFILES[quality]
|
||||
if profile is None or secondary:
|
||||
return super()._build_cache_dit_config(
|
||||
num_inference_steps,
|
||||
steps_computation_mask,
|
||||
scm_policy,
|
||||
secondary=secondary,
|
||||
)
|
||||
warmup, threshold, max_cached = profile
|
||||
return CacheDitConfig(
|
||||
enabled=True,
|
||||
Fn_compute_blocks=1,
|
||||
Bn_compute_blocks=0,
|
||||
max_warmup_steps=warmup,
|
||||
residual_diff_threshold=threshold,
|
||||
max_continuous_cached_steps=max_cached,
|
||||
enable_taylorseer=False,
|
||||
taylorseer_order=1,
|
||||
num_inference_steps=num_inference_steps,
|
||||
steps_computation_mask=steps_computation_mask,
|
||||
steps_computation_policy=scm_policy,
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
with self._offload_for_torch_compile_warmup(batch):
|
||||
return self._forward_native(batch, server_args)
|
||||
|
||||
def _forward_native(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
MINIMAX_H3_SIGMAS_EXTRA_KEY,
|
||||
MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY,
|
||||
)
|
||||
|
||||
if MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY in batch.extra:
|
||||
for required in (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
MINIMAX_H3_SIGMAS_EXTRA_KEY,
|
||||
):
|
||||
if required not in batch.extra:
|
||||
raise ValueError(
|
||||
f"direct full-loop denoise requires batch.extra[{required!r}]"
|
||||
)
|
||||
self._run_full_loop(batch, server_args)
|
||||
return batch
|
||||
if (
|
||||
batch.latents is not None
|
||||
or batch.audio_latents is not None
|
||||
or batch.timestep is not None
|
||||
or batch.timesteps is not None
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"MiniMaxH3DenoisingStage requires the canonical direct pipeline "
|
||||
"to populate text embeddings, denoise state, and sigma schedules."
|
||||
)
|
||||
return batch
|
||||
|
||||
def _run_full_loop(self, batch: Req, server_args: ServerArgs) -> None:
|
||||
"""Assemble the cfg-distilled positive input and run the full loop.
|
||||
|
||||
The heavy lifting is decomposed into per-phase helpers:
|
||||
context/payload resolution, condition-row assembly, packed-layout
|
||||
construction, condition noise augmentation, initial-row expansion,
|
||||
the denoise loop itself, and output publication.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.denoise_loop import (
|
||||
MiniMaxH3DenoiseBranch,
|
||||
minimax_h3_denoise_loop,
|
||||
)
|
||||
|
||||
ctx = _resolve_full_loop_context(batch)
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("MiniMax H3 full-loop denoise requires CUDA")
|
||||
device = torch.device("cuda")
|
||||
sigmas_video = [float(v) for v in ctx.sigmas["video"]]
|
||||
self._maybe_enable_cache_dit_and_torch_compile(
|
||||
len(sigmas_video) - 1,
|
||||
batch,
|
||||
)
|
||||
|
||||
_assemble_condition_rows(ctx)
|
||||
|
||||
emb = ctx.embeddings["positive"]
|
||||
packed = _build_packed_layout(ctx, emb)
|
||||
tags = packed["token_tags"]
|
||||
tags[packed["text_pos"].view(-1)] = (
|
||||
emb["text_token_tags"].view(-1).to(torch.long)
|
||||
)
|
||||
|
||||
sampling = batch.sampling_params
|
||||
imgvid_noise_aug, audio_noise_aug = minimax_h3_condition_noise_aug(sampling)
|
||||
_apply_condition_noise_aug(
|
||||
ctx,
|
||||
sampling=sampling,
|
||||
imgvid_noise_aug=imgvid_noise_aug,
|
||||
audio_noise_aug=audio_noise_aug,
|
||||
)
|
||||
|
||||
placement_managed = self._component_residency_manager is not None
|
||||
if placement_managed:
|
||||
self._manage_dit_use_site(self.transformer, "transformer", batch)
|
||||
try:
|
||||
model = _resolve_denoise_model(
|
||||
self.transformer,
|
||||
device,
|
||||
placement_managed=placement_managed,
|
||||
)
|
||||
positive = MiniMaxH3DenoiseBranch(
|
||||
packed=packed,
|
||||
text_embeddings=emb["hidden_states"],
|
||||
token_tags=tags,
|
||||
device=device,
|
||||
)
|
||||
_precompute_refined_prompt_embeds(
|
||||
model,
|
||||
positive,
|
||||
device=device,
|
||||
)
|
||||
_precompute_rope_cache(
|
||||
model,
|
||||
positive,
|
||||
device=device,
|
||||
)
|
||||
initial_video, initial_audio = _expand_initial_rows(ctx, positive)
|
||||
with (
|
||||
maybe_nvtx_range("denoising_loop", self.current_use_nvtx),
|
||||
self.progress_bar(
|
||||
total=len(sigmas_video) - 1,
|
||||
batch=batch,
|
||||
desc="minimax_h3 denoise",
|
||||
) as progress_bar,
|
||||
):
|
||||
|
||||
def on_step(_step, _video_rows, _audio_rows):
|
||||
progress_bar.update()
|
||||
if not batch.is_warmup:
|
||||
self.step_profile()
|
||||
|
||||
video_rows, audio_rows = minimax_h3_denoise_loop(
|
||||
model=model,
|
||||
model_forward=partial(self._forward_dit, batch=batch),
|
||||
positive=positive,
|
||||
initial_video_rows=initial_video,
|
||||
initial_audio_rows=initial_audio,
|
||||
keyframe_cond_rows=ctx.cond_rows,
|
||||
audio_ref_rows=ctx.audio_ref_rows,
|
||||
sigmas_video=sigmas_video,
|
||||
sigmas_audio=[float(v) for v in ctx.sigmas["audio"]],
|
||||
device=device,
|
||||
imgvid_cond_noise_aug_for_inference=float(imgvid_noise_aug),
|
||||
audio_cond_noise_aug_for_inference=float(audio_noise_aug),
|
||||
on_step=on_step,
|
||||
step_profiler=partial(
|
||||
self._profile_denoising_step,
|
||||
batch=batch,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
self._finish_active_component_use()
|
||||
_publish_full_loop_outputs(
|
||||
ctx,
|
||||
batch=batch,
|
||||
positive=positive,
|
||||
video_rows=video_rows,
|
||||
audio_rows=audio_rows,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _profile_denoising_step(self, step_index: int, *, batch: Req):
|
||||
with (
|
||||
maybe_nvtx_range(
|
||||
f"denoising_step_{step_index}",
|
||||
self.current_use_nvtx,
|
||||
),
|
||||
StageProfiler(
|
||||
f"denoising_step_{step_index}",
|
||||
logger=logger,
|
||||
metrics=batch.metrics,
|
||||
perf_dump_path_provided=batch.perf_dump_path is not None,
|
||||
record_as_step=True,
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
def _forward_dit(
|
||||
self,
|
||||
model: Any,
|
||||
call_kwargs: dict[str, Any],
|
||||
step_index: int,
|
||||
*,
|
||||
batch: Req,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Route the custom full loop through the native denoising runner."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.managers.forward_context import (
|
||||
set_forward_context,
|
||||
)
|
||||
|
||||
with set_forward_context(
|
||||
current_timestep=step_index,
|
||||
attn_metadata=None,
|
||||
forward_batch=batch,
|
||||
):
|
||||
runner = self._maybe_get_bcg_runner(model)
|
||||
if runner is None:
|
||||
return model(**call_kwargs)
|
||||
return self._bcg_run(runner, call_kwargs, model)
|
||||
|
||||
def verify_output(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(5)])
|
||||
result.add_check(
|
||||
"audio_latents",
|
||||
batch.audio_latents,
|
||||
[V.is_tensor, V.with_dims(3)],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class _FullLoopContext:
|
||||
"""Mutable per-request state threaded through the full-loop phases."""
|
||||
|
||||
__slots__ = (
|
||||
"plan",
|
||||
"keyframe",
|
||||
"ref_image",
|
||||
"ref_audio",
|
||||
"ref_video",
|
||||
"is_ref2va",
|
||||
"embeddings",
|
||||
"state",
|
||||
"sigmas",
|
||||
"latent_t",
|
||||
"latent_h",
|
||||
"latent_w",
|
||||
"audio_t",
|
||||
"ref2va_positive_blocks",
|
||||
"cond_rows",
|
||||
"audio_ref_rows",
|
||||
"include_cond",
|
||||
"keyframe_frame_indices",
|
||||
"keyframe_frame_count",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
for name in self.__slots__:
|
||||
setattr(self, name, None)
|
||||
self.is_ref2va = False
|
||||
self.include_cond = False
|
||||
|
||||
|
||||
def _resolve_full_loop_context(batch: Req) -> _FullLoopContext:
|
||||
"""Read/validate extras and the denoise state into a loop context.
|
||||
|
||||
Enforces the task-payload exclusivity rules (keyframe vs reference
|
||||
exclusivity) and cross-checks the resolved latent dims.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_SIGMAS_EXTRA_KEY,
|
||||
MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
ctx = _FullLoopContext()
|
||||
ctx.embeddings = batch.extra[MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY]
|
||||
ctx.state = batch.extra[MINIMAX_H3_DENOISE_STATE_EXTRA_KEY]
|
||||
ctx.sigmas = batch.extra[MINIMAX_H3_SIGMAS_EXTRA_KEY]
|
||||
ctx.plan = minimax_h3_plan_from_batch(batch)
|
||||
ctx.keyframe = batch.extra.get(MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY)
|
||||
ctx.ref_image = batch.extra.get(MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY)
|
||||
ctx.ref_audio = batch.extra.get(MINIMAX_H3_REFERENCE_AUDIO_ROWS_EXTRA_KEY)
|
||||
ctx.ref_video = batch.extra.get(MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY)
|
||||
ctx.is_ref2va = (
|
||||
ctx.ref_image is not None
|
||||
or ctx.ref_audio is not None
|
||||
or ctx.ref_video is not None
|
||||
)
|
||||
if ctx.is_ref2va and ctx.keyframe is not None:
|
||||
raise ValueError("keyframe and reference extras are mutually exclusive")
|
||||
_validate_fl2va_keyframe_payload(ctx.plan, ctx.keyframe)
|
||||
|
||||
ctx.latent_t = int(ctx.state["latent_t"])
|
||||
ctx.latent_h = int(ctx.state["latent_h"])
|
||||
ctx.latent_w = int(ctx.state["latent_w"])
|
||||
ctx.audio_t = int(ctx.state["audio_t"])
|
||||
return ctx
|
||||
|
||||
|
||||
def _assemble_condition_rows(ctx: _FullLoopContext) -> None:
|
||||
"""Populate cond/audio reference rows and keyframe metadata per task."""
|
||||
|
||||
ctx.include_cond = (
|
||||
ctx.keyframe is not None
|
||||
or ctx.ref_image is not None
|
||||
or ctx.ref_video is not None
|
||||
)
|
||||
if ctx.is_ref2va:
|
||||
if ctx.plan is None:
|
||||
raise ValueError(
|
||||
"ref2va reference extras require a resolved plan; "
|
||||
"plan-less ref2va requests are unsupported"
|
||||
)
|
||||
ctx.ref2va_positive_blocks, ctx.cond_rows, ctx.audio_ref_rows = (
|
||||
_ref2va_ordered_blocks_and_rows(
|
||||
plan=ctx.plan,
|
||||
ref_image=ctx.ref_image,
|
||||
ref_audio=ctx.ref_audio,
|
||||
ref_video=ctx.ref_video,
|
||||
)
|
||||
)
|
||||
ctx.include_cond = ctx.cond_rows is not None
|
||||
else:
|
||||
ctx.cond_rows = ctx.keyframe["rows"] if ctx.include_cond else None
|
||||
ctx.audio_ref_rows = (
|
||||
ctx.ref_audio["rows"] if ctx.ref_audio is not None else None
|
||||
)
|
||||
if ctx.keyframe is not None:
|
||||
raw_indices = ctx.keyframe.get("semantic_frame_indices")
|
||||
ctx.keyframe_frame_indices = [int(v) for v in raw_indices]
|
||||
ctx.keyframe_frame_count = int(ctx.keyframe["frame_count"])
|
||||
|
||||
|
||||
def _build_packed_layout(
|
||||
ctx: _FullLoopContext,
|
||||
emb: Mapping[str, Any],
|
||||
) -> dict[str, torch.Tensor]:
|
||||
"""Build the per-task packed layout for the positive branch."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
|
||||
if ctx.is_ref2va:
|
||||
if ctx.ref2va_positive_blocks is None:
|
||||
raise ValueError("ref2va ordered reference blocks missing")
|
||||
packed = minimax_h3_packed_sequence_ref2va_blocks(
|
||||
text_len=int(emb["text_len"]),
|
||||
latent_t=ctx.latent_t,
|
||||
latent_h=ctx.latent_h,
|
||||
latent_w=ctx.latent_w,
|
||||
audio_t=ctx.audio_t,
|
||||
ref_blocks=ctx.ref2va_positive_blocks,
|
||||
)
|
||||
else:
|
||||
packed = minimax_h3_packed_sequence(
|
||||
text_len=int(emb["text_len"]),
|
||||
latent_t=ctx.latent_t,
|
||||
latent_h=ctx.latent_h,
|
||||
latent_w=ctx.latent_w,
|
||||
audio_t=ctx.audio_t,
|
||||
include_keyframe_cond=ctx.include_cond,
|
||||
keyframe_frame_indices=(
|
||||
ctx.keyframe_frame_indices if ctx.include_cond else None
|
||||
),
|
||||
frame_count=ctx.keyframe_frame_count,
|
||||
)
|
||||
return packed
|
||||
|
||||
|
||||
def _condition_audio_lengths(ctx: _FullLoopContext) -> list[int]:
|
||||
"""Per-task condition audio T list for the noise-aug recipe."""
|
||||
|
||||
condition_audio_t: list[int] = []
|
||||
if ctx.ref2va_positive_blocks is not None:
|
||||
for block in ctx.ref2va_positive_blocks:
|
||||
if str(block["kind"]) in {"audio", "video", "video_audio"}:
|
||||
ref_audio_t = int(block["ref_audio_t"])
|
||||
if ref_audio_t > 0:
|
||||
condition_audio_t.append(ref_audio_t)
|
||||
elif isinstance(ctx.ref_audio, Mapping):
|
||||
entries = ctx.ref_audio.get("audios")
|
||||
if isinstance(entries, list):
|
||||
condition_audio_t.extend(
|
||||
int(entry["ref_audio_t"])
|
||||
for entry in entries
|
||||
if int(entry["ref_audio_t"]) > 0
|
||||
)
|
||||
elif ctx.ref_audio.get("ref_audio_t") is not None:
|
||||
ref_audio_t = int(ctx.ref_audio["ref_audio_t"])
|
||||
if ref_audio_t > 0:
|
||||
condition_audio_t.append(ref_audio_t)
|
||||
return condition_audio_t
|
||||
|
||||
|
||||
def _apply_condition_noise_aug(
|
||||
ctx: _FullLoopContext,
|
||||
*,
|
||||
sampling: Any,
|
||||
imgvid_noise_aug: float,
|
||||
audio_noise_aug: float,
|
||||
) -> None:
|
||||
"""Apply condition noise augmentation to cond rows."""
|
||||
|
||||
noise_visual_conditions = (
|
||||
ctx.cond_rows is not None and float(imgvid_noise_aug) < 1.0
|
||||
)
|
||||
noise_audio_conditions = (
|
||||
ctx.audio_ref_rows is not None and float(audio_noise_aug) < 1.0
|
||||
)
|
||||
if not (noise_visual_conditions or noise_audio_conditions):
|
||||
return
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.condition_noise import (
|
||||
minimax_h3_audio_cond_noise_aug_rows,
|
||||
minimax_h3_imgvid_cond_noise_aug_rows,
|
||||
)
|
||||
|
||||
noise_seed = getattr(ctx.plan, "seed", None) if ctx.plan is not None else None
|
||||
if noise_seed is None:
|
||||
noise_seed = getattr(sampling, "seed", None)
|
||||
if noise_seed is None:
|
||||
noise_seed = 42
|
||||
|
||||
if noise_visual_conditions:
|
||||
condition_shapes = _imgvid_condition_shapes(
|
||||
ref2va_blocks=ctx.ref2va_positive_blocks,
|
||||
keyframe=ctx.keyframe,
|
||||
is_ref2va=ctx.is_ref2va,
|
||||
)
|
||||
imgvid_cond_num_frames = len(condition_shapes)
|
||||
if not condition_shapes:
|
||||
raise ValueError("imgvid condition rows are missing shape metadata")
|
||||
# Imgvid conditions (ref2va blocks / keyframes) contribute one frame
|
||||
# count per condition entry.
|
||||
ctx.cond_rows = minimax_h3_imgvid_cond_noise_aug_rows(
|
||||
ctx.cond_rows,
|
||||
condition_shapes=condition_shapes,
|
||||
target_latent_t=ctx.latent_t,
|
||||
imgvid_cond_num_frames=imgvid_cond_num_frames,
|
||||
seed=int(noise_seed),
|
||||
noise_aug=float(imgvid_noise_aug),
|
||||
)
|
||||
|
||||
if noise_audio_conditions:
|
||||
condition_audio_t = _condition_audio_lengths(ctx)
|
||||
if not condition_audio_t:
|
||||
raise ValueError("audio condition rows are missing length metadata")
|
||||
ctx.audio_ref_rows = minimax_h3_audio_cond_noise_aug_rows(
|
||||
ctx.audio_ref_rows,
|
||||
condition_audio_t=condition_audio_t,
|
||||
seed=int(noise_seed),
|
||||
noise_aug=float(audio_noise_aug),
|
||||
)
|
||||
|
||||
|
||||
def _expand_initial_rows(
|
||||
ctx: _FullLoopContext,
|
||||
positive: Any,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Scatter target-row noise into the full packed layout when cond rows exist."""
|
||||
|
||||
initial_video = ctx.state["initial_video_rows"]
|
||||
if ctx.include_cond:
|
||||
# layout target rows = noise; cond anchors appended by the loop
|
||||
n_video = positive.img_pos.shape[0]
|
||||
full = torch.zeros(int(n_video), initial_video.shape[1], dtype=torch.float32)
|
||||
full[positive.update_mask] = initial_video
|
||||
initial_video = full
|
||||
|
||||
initial_audio = ctx.state["initial_audio_rows"]
|
||||
if ctx.audio_ref_rows is not None:
|
||||
n_audio = positive.audio_pos.shape[0]
|
||||
full_audio = torch.zeros(
|
||||
int(n_audio), initial_audio.shape[1], dtype=torch.float32
|
||||
)
|
||||
full_audio[positive.audio_update_mask] = initial_audio
|
||||
initial_audio = full_audio
|
||||
return initial_video, initial_audio
|
||||
|
||||
|
||||
def _publish_full_loop_outputs(
|
||||
ctx: _FullLoopContext,
|
||||
*,
|
||||
batch: Req,
|
||||
positive: Any,
|
||||
video_rows: torch.Tensor,
|
||||
audio_rows: torch.Tensor,
|
||||
) -> None:
|
||||
"""Compose the generated target latents onto the batch."""
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_tokens import (
|
||||
minimax_h3_unpack_audio_tokens,
|
||||
minimax_h3_unpatchify_video_tokens,
|
||||
)
|
||||
|
||||
target_rows = video_rows[positive.video_target_slice]
|
||||
# Keep latents on CUDA so decode can reuse them without a device round-trip;
|
||||
# decode autocast is enabled only for CUDA inputs.
|
||||
batch.latents = minimax_h3_unpatchify_video_tokens(
|
||||
target_rows,
|
||||
latent_shape=[ctx.latent_t, ctx.latent_h // 2, ctx.latent_w // 2, 24],
|
||||
patch_size=[1, 2, 2],
|
||||
)
|
||||
audio_target_rows = audio_rows[positive.audio_target_slice]
|
||||
batch.audio_latents = minimax_h3_unpack_audio_tokens(
|
||||
audio_target_rows, audio_t=ctx.audio_t * 2, audio_channel=2
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3DenoisingStage",
|
||||
"minimax_h3_condition_noise_aug",
|
||||
]
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class MiniMaxH3LatentPreparationStage(PipelineStage):
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} is a MiniMax H3 contract stage "
|
||||
"and has no implementation yet."
|
||||
)
|
||||
self._prepare_denoise_state_from_plan(batch, plan)
|
||||
self._publish_native_latent_state(batch)
|
||||
return batch
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> list[Req]:
|
||||
"""Preserve H3's independent per-modality RNG streams per request."""
|
||||
return [self(batch, server_args) for batch in batches]
|
||||
|
||||
@staticmethod
|
||||
def _publish_native_latent_state(batch: Req) -> None:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
)
|
||||
|
||||
state = batch.extra.get(MINIMAX_H3_DENOISE_STATE_EXTRA_KEY)
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("MiniMax H3 denoise state must be a mapping")
|
||||
video_rows = state.get("initial_video_rows")
|
||||
audio_rows = state.get("initial_audio_rows")
|
||||
if not isinstance(video_rows, torch.Tensor) or video_rows.ndim != 2:
|
||||
raise ValueError("MiniMax H3 initial_video_rows must be a rank-2 tensor")
|
||||
if not isinstance(audio_rows, torch.Tensor) or audio_rows.ndim != 2:
|
||||
raise ValueError("MiniMax H3 initial_audio_rows must be a rank-2 tensor")
|
||||
|
||||
latent_t = int(state["latent_t"])
|
||||
latent_h = int(state["latent_h"])
|
||||
latent_w = int(state["latent_w"])
|
||||
audio_t = int(state["audio_t"])
|
||||
batch.latents = video_rows
|
||||
batch.audio_latents = audio_rows
|
||||
batch.raw_latent_shape = (1, 24, latent_t, latent_h, latent_w)
|
||||
batch.raw_audio_latent_shape = (2, 32, audio_t)
|
||||
|
||||
def _prepare_denoise_state_from_plan(self, batch: Req, plan) -> None:
|
||||
"""Direct initial-noise materialization (t2va recipe):
|
||||
torch.Generator().manual_seed(seed); video rows drawn first,
|
||||
then audio rows, CPU fp32. Every task consumes the final latent grid
|
||||
frozen by the pre-queue shape resolver."""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_DENOISE_STATE_EXTRA_KEY,
|
||||
)
|
||||
|
||||
if MINIMAX_H3_DENOISE_STATE_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
shape = plan.shape
|
||||
geometry = str(shape["geometry"])
|
||||
if geometry != "resolved_v2":
|
||||
raise ValueError(
|
||||
"MiniMax H3 latent preparation requires pre-queue resolved_v2 "
|
||||
f"geometry, got {geometry!r}"
|
||||
)
|
||||
latent_h = int(shape["height"]) // 16
|
||||
latent_w = int(shape["width"]) // 16
|
||||
if shape.get("video_latent_t") is None or shape.get("audio_latent_t") is None:
|
||||
raise ValueError(
|
||||
"MiniMax H3 latent preparation requires pre-queue resolved "
|
||||
"temporal dimensions"
|
||||
)
|
||||
latent_t = int(shape["video_latent_t"])
|
||||
audio_t = int(shape["audio_latent_t"])
|
||||
|
||||
seed = plan.seed
|
||||
if seed is None:
|
||||
seed = 42 # pinned default seed
|
||||
video_rows_n = latent_t * (latent_h // 2) * (latent_w // 2)
|
||||
audio_rows_n = audio_t * 2
|
||||
# Noise semantics:
|
||||
# - video noise is drawn on the RAW latent tensor
|
||||
# [1, 24, T, H_lat, W_lat] in tensor layout, then patchified
|
||||
# into packed row order;
|
||||
# - audio uses an INDEPENDENT generator re-seeded with the same
|
||||
# seed (each modality re-seeds its own generator);
|
||||
# - no extra cond-frame noise is drawn for image-conditioned
|
||||
# requests.
|
||||
# The same seed always reproduces the same noise.
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.packed_tokens import (
|
||||
minimax_h3_patchify_video_latent,
|
||||
)
|
||||
|
||||
gen_v = torch.Generator().manual_seed(int(seed))
|
||||
video_tensor = torch.randn(
|
||||
1,
|
||||
24,
|
||||
latent_t,
|
||||
latent_h,
|
||||
latent_w,
|
||||
generator=gen_v,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
video_noise = minimax_h3_patchify_video_latent(
|
||||
video_tensor, patch_size=[1, 2, 2]
|
||||
).to(torch.float32)
|
||||
gen_a = torch.Generator().manual_seed(int(seed))
|
||||
audio_noise = torch.randn(
|
||||
audio_rows_n, 32, generator=gen_a, dtype=torch.float32
|
||||
)
|
||||
if list(video_noise.shape) != [video_rows_n, 96]:
|
||||
raise ValueError(
|
||||
f"aligned video noise shape {list(video_noise.shape)} != "
|
||||
f"[{video_rows_n}, 96]"
|
||||
)
|
||||
batch.extra[MINIMAX_H3_DENOISE_STATE_EXTRA_KEY] = {
|
||||
"initial_video_rows": video_noise,
|
||||
"initial_audio_rows": audio_noise,
|
||||
"latent_t": latent_t,
|
||||
"latent_h": latent_h,
|
||||
"latent_w": latent_w,
|
||||
"audio_t": audio_t,
|
||||
}
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check(
|
||||
"prompt_or_embeds",
|
||||
None,
|
||||
lambda _: V.string_or_list_strings(batch.prompt)
|
||||
or V.list_not_empty(batch.prompt_embeds),
|
||||
)
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors)
|
||||
result.add_check(
|
||||
"num_videos_per_prompt", batch.num_outputs_per_prompt, V.positive_int
|
||||
)
|
||||
result.add_check("generator", batch.generator, V.generator_or_list_generators)
|
||||
result.add_check("num_frames", batch.num_frames, V.positive_int)
|
||||
result.add_check("height", batch.height, V.positive_int)
|
||||
result.add_check("width", batch.width, V.positive_int)
|
||||
result.add_check("latents", batch.latents, V.none_or_tensor)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("latents", batch.latents, [V.is_tensor, V.with_dims(2)])
|
||||
result.add_check(
|
||||
"audio_latents", batch.audio_latents, [V.is_tensor, V.with_dims(2)]
|
||||
)
|
||||
result.add_check("raw_latent_shape", batch.raw_latent_shape, V.is_tuple)
|
||||
result.add_check(
|
||||
"raw_audio_latent_shape", batch.raw_audio_latent_shape, V.is_tuple
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MiniMaxH3LatentPreparationStage",
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Request-replica broadcast helpers for MiniMax H3 encoding stages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def minimax_h3_replica_ctx() -> tuple[int, int]:
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_world_group,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
|
||||
if not model_parallel_is_initialized():
|
||||
return 1, 0
|
||||
# ServerArgs currently rejects DP>1 and H3 rejects disaggregation, so the
|
||||
# world group contains exactly one request replica (TP/CFG/SP ranks).
|
||||
group = get_world_group()
|
||||
return int(group.world_size), int(group.rank_in_group)
|
||||
|
||||
|
||||
def minimax_h3_replica_broadcast_extra(batch: Any, key: str) -> None:
|
||||
world, rank = minimax_h3_replica_ctx()
|
||||
if world <= 1:
|
||||
return
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_world_group,
|
||||
)
|
||||
|
||||
group = get_world_group()
|
||||
payload = {"value": batch.extra.get(key)} if rank == 0 else None
|
||||
payload = group.broadcast_tensor_dict(payload, src=0)
|
||||
value = payload.get("value") if isinstance(payload, dict) else None
|
||||
if value is None:
|
||||
raise RuntimeError(f"replica broadcast of batch.extra[{key!r}] got None")
|
||||
if rank != 0:
|
||||
batch.extra[key] = value
|
||||
|
||||
|
||||
def minimax_h3_replica_broadcast_error(error: str | None) -> str | None:
|
||||
world, rank = minimax_h3_replica_ctx()
|
||||
if world <= 1:
|
||||
return error
|
||||
|
||||
from sglang.multimodal_gen.runtime.distributed.parallel_state import (
|
||||
get_world_group,
|
||||
)
|
||||
|
||||
group = get_world_group()
|
||||
return group.broadcast_object(error if rank == 0 else None, src=0)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"minimax_h3_replica_broadcast_error",
|
||||
"minimax_h3_replica_broadcast_extra",
|
||||
"minimax_h3_replica_ctx",
|
||||
]
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.managers.forward_context import set_forward_context
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.text_encoding import (
|
||||
TextEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class MiniMaxH3TextEncodingStage(TextEncodingStage):
|
||||
deduplicated_output_fields = ("prompt_embeds", "prompt_seq_lens")
|
||||
deduplicated_extra_output_keys = (MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY,)
|
||||
|
||||
def __init__(self, text_encoder, tokenizer, processor) -> None:
|
||||
super().__init__(
|
||||
text_encoders=[text_encoder],
|
||||
tokenizers=[tokenizer],
|
||||
)
|
||||
self.text_encoder = text_encoder
|
||||
self.tokenizer = tokenizer
|
||||
if processor is None:
|
||||
raise ValueError(
|
||||
"MiniMaxH3TextEncodingStage requires the pipeline processor "
|
||||
"component (model_index.json: processor)"
|
||||
)
|
||||
self.processor = processor
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is not None:
|
||||
try:
|
||||
self._encode_from_plan(batch, plan)
|
||||
self._publish_native_text_conditioning(batch)
|
||||
except Exception:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_cleanup_temp_dirs,
|
||||
)
|
||||
|
||||
minimax_h3_cleanup_temp_dirs(batch)
|
||||
raise
|
||||
return batch
|
||||
if batch.sampling_params is not None and (
|
||||
batch.sampling_params.prompt is not None
|
||||
or batch.sampling_params.prompt_path is not None
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"MiniMaxH3TextEncodingStage direct Qwen3VL encoder forward requires "
|
||||
"a canonical minimax_h3 request (resolved plan); legacy prompt-only "
|
||||
"requests are unsupported."
|
||||
)
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args: ServerArgs):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
return super().build_dedup_fingerprint(batch, server_args)
|
||||
materials = tuple(
|
||||
(
|
||||
item.condition_index,
|
||||
item.role,
|
||||
item.condition_type,
|
||||
item.uri,
|
||||
item.material_chain,
|
||||
item.frame_index,
|
||||
item.resolved_frame_index,
|
||||
item.start_time_seconds,
|
||||
)
|
||||
for item in plan.materials
|
||||
)
|
||||
return (
|
||||
plan.task,
|
||||
plan.prompt,
|
||||
materials,
|
||||
self.freeze_for_dedup(plan.shape),
|
||||
)
|
||||
|
||||
def run_grouped_requests(
|
||||
self,
|
||||
batches: list[Req],
|
||||
server_args: ServerArgs,
|
||||
) -> list[Req]:
|
||||
"""Distribute independent H3 presentations over replicated encoders.
|
||||
|
||||
H3 presentations have variable multimodal layouts, so they cannot be
|
||||
stacked into the generic text batch without changing padding/kernels.
|
||||
Assigning one complete request to a rank preserves the exact
|
||||
single-request encoder path, then broadcasts that request's native
|
||||
payload to the other ranks.
|
||||
"""
|
||||
grouped = self._group_requests_by_fingerprint(
|
||||
batches,
|
||||
lambda batch: self.build_dedup_fingerprint(batch, server_args),
|
||||
)
|
||||
if not grouped:
|
||||
return []
|
||||
|
||||
encoder_config = server_args.pipeline_config.text_encoder_configs[0]
|
||||
dp_group = self._text_encode_dp_group(
|
||||
server_args,
|
||||
encoder_config,
|
||||
len(grouped),
|
||||
self.text_encoder,
|
||||
)
|
||||
if dp_group is None:
|
||||
return super().run_grouped_requests(batches, server_args)
|
||||
|
||||
results: list[Req | None] = [None] * len(batches)
|
||||
for group_index, (_, equivalent) in enumerate(grouped):
|
||||
owner = group_index % dp_group.world_size
|
||||
first_index, first_batch = equivalent[0]
|
||||
owner_exception = None
|
||||
owner_error = None
|
||||
payload = None
|
||||
if dp_group.rank_in_group == owner:
|
||||
try:
|
||||
first_result = self(first_batch, server_args)
|
||||
payload = first_result.extra.get(
|
||||
MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(
|
||||
"MiniMax H3 text encode produced no native payload"
|
||||
)
|
||||
except Exception as exc:
|
||||
owner_exception = exc
|
||||
owner_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
owner_error = dp_group.broadcast_object(owner_error, src=owner)
|
||||
if owner_error is not None:
|
||||
if owner_exception is not None:
|
||||
raise owner_exception
|
||||
raise RuntimeError(
|
||||
f"MiniMax H3 text encode failed on rank {owner}: {owner_error}"
|
||||
)
|
||||
payload = dp_group.broadcast_tensor_dict(payload, src=owner)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("MiniMax H3 text payload broadcast failed")
|
||||
|
||||
if dp_group.rank_in_group != owner:
|
||||
first_batch.extra[MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY] = payload
|
||||
self._publish_native_text_conditioning(first_batch)
|
||||
first_result = first_batch
|
||||
results[first_index] = first_result
|
||||
|
||||
for index, batch in equivalent[1:]:
|
||||
self.copy_deduplicated_outputs(first_result, batch)
|
||||
results[index] = batch
|
||||
|
||||
return [result for result in results if result is not None]
|
||||
|
||||
def _log_dp_choice(self, batch_size: int, world_size: int) -> None:
|
||||
if self._dp_choice_logged:
|
||||
return
|
||||
self._dp_choice_logged = True
|
||||
logger.info(
|
||||
"encoder_parallel: distributing %d independent MiniMax H3 "
|
||||
"presentations over %d replicated encoder ranks",
|
||||
batch_size,
|
||||
world_size,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _publish_native_text_conditioning(batch: Req) -> None:
|
||||
"""Mirror H3's rich payload onto the native text-stage fields.
|
||||
|
||||
H3 keeps token tags and presentation metadata in ``Req.extra``, but
|
||||
the shared TextEncodingStage contract still owns ``prompt_embeds``.
|
||||
Publishing the same tensor there preserves native verification,
|
||||
grouped-request deduplication, and downstream memory accounting
|
||||
without duplicating the embedding storage.
|
||||
"""
|
||||
payload = batch.extra.get(MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY)
|
||||
positive = payload.get("positive") if isinstance(payload, dict) else None
|
||||
hidden_states = (
|
||||
positive.get("hidden_states") if isinstance(positive, dict) else None
|
||||
)
|
||||
text_len = positive.get("text_len") if isinstance(positive, dict) else None
|
||||
if not isinstance(hidden_states, torch.Tensor) or hidden_states.ndim < 2:
|
||||
raise ValueError(
|
||||
"MiniMax H3 text payload must contain positive.hidden_states "
|
||||
"with at least two dimensions"
|
||||
)
|
||||
if not isinstance(text_len, int) or text_len != int(hidden_states.shape[0]):
|
||||
raise ValueError(
|
||||
"MiniMax H3 text payload positive.text_len must match the "
|
||||
"hidden-state sequence dimension"
|
||||
)
|
||||
batch.prompt_embeds = [hidden_states]
|
||||
batch.prompt_seq_lens = [[text_len]]
|
||||
|
||||
def _encode_from_plan(self, batch: Req, plan) -> None:
|
||||
"""Encode the positive Qwen3VL presentation into layer-50 states.
|
||||
|
||||
MiniMax H3 only supports the CFG-distilled model path, so every task
|
||||
emits exactly one positive embedding payload. ComponentManager owns
|
||||
residency/offload, while every folded-TP rank enters the encoder
|
||||
collectives and receives the same replicated hidden states.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.presentation import (
|
||||
minimax_h3_text_only_ids,
|
||||
)
|
||||
|
||||
prompt = plan.prompt
|
||||
keyframes = [
|
||||
m for m in plan.materials if m.material_chain == "image.target_canvas"
|
||||
]
|
||||
if plan.task == "fl2va":
|
||||
frame_indices = tuple(material.frame_index for material in keyframes)
|
||||
if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"fl2va text encoding requires an ordered keyframe signature "
|
||||
f"in {MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got "
|
||||
f"{frame_indices!r}"
|
||||
)
|
||||
elif keyframes:
|
||||
raise ValueError(
|
||||
f"task {plan.task!r} cannot carry image.target_canvas materials"
|
||||
)
|
||||
if MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
if self.text_encoder is None:
|
||||
raise ValueError(
|
||||
"MiniMaxH3TextEncodingStage direct encode requires a text_encoder "
|
||||
"component"
|
||||
)
|
||||
encode_ids = getattr(self.text_encoder, "encode_ids", None)
|
||||
if not callable(encode_ids):
|
||||
raise TypeError(
|
||||
"MiniMax H3 text_encoder component must expose callable "
|
||||
"encode_ids(...) for direct encode (MiniMaxH3Qwen3VLEncoder)"
|
||||
)
|
||||
if self.tokenizer is None:
|
||||
raise ValueError(
|
||||
"MiniMaxH3TextEncodingStage direct encode requires a tokenizer component"
|
||||
)
|
||||
self._manage_text_encoder_use(0)
|
||||
with set_forward_context(current_timestep=0, attn_metadata=None):
|
||||
if plan.task == "ref2va":
|
||||
embeddings = self._encode_ref2va(batch, plan, encode_ids)
|
||||
elif keyframes:
|
||||
embeddings = self._encode_fl2va_keyframes(
|
||||
batch,
|
||||
plan,
|
||||
encode_ids,
|
||||
prompt=prompt,
|
||||
)
|
||||
else:
|
||||
positive_ids = minimax_h3_text_only_ids(self.tokenizer, prompt)
|
||||
embeddings = {
|
||||
"positive": {
|
||||
"hidden_states": encode_ids(positive_ids),
|
||||
"text_len": int(positive_ids.shape[0]),
|
||||
"text_token_tags": torch.ones(
|
||||
int(positive_ids.shape[0]), dtype=torch.long
|
||||
),
|
||||
}
|
||||
}
|
||||
batch.extra[MINIMAX_H3_TEXT_EMBEDDINGS_EXTRA_KEY] = embeddings
|
||||
|
||||
def _encode_fl2va_keyframes(
|
||||
self,
|
||||
batch: Req,
|
||||
plan,
|
||||
encode_ids,
|
||||
*,
|
||||
prompt: str,
|
||||
) -> dict:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.canvas import (
|
||||
minimax_h3_prepared_keyframes,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.presentation import (
|
||||
minimax_h3_multi_image_presentation,
|
||||
)
|
||||
|
||||
# The SAME prepared target-canvas images feed
|
||||
# Qwen and the visual-condition tokenizer; preparation is cached per request.
|
||||
prepared = minimax_h3_prepared_keyframes(batch, plan)
|
||||
images = [item["image"] for item in prepared["images"]]
|
||||
frame_indices = tuple(prepared.get("semantic_frame_indices") or ())
|
||||
if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES or len(
|
||||
images
|
||||
) != len(frame_indices):
|
||||
raise ValueError(
|
||||
"fl2va Qwen preparation requires one or two ordered images with "
|
||||
"a supported semantic_frame_indices signature, got "
|
||||
f"{frame_indices!r}"
|
||||
)
|
||||
processor = self.processor
|
||||
vision = processor.image_processor(images=images, return_tensors="pt")
|
||||
pixel_values = vision["pixel_values"]
|
||||
image_grid_thw = vision["image_grid_thw"]
|
||||
if int(image_grid_thw.shape[0]) != len(images):
|
||||
raise ValueError(
|
||||
f"expected {len(images)} image grids, got {list(image_grid_thw.shape)}"
|
||||
)
|
||||
merge = int(processor.image_processor.merge_size) ** 2
|
||||
image_token_counts = [
|
||||
int(image_grid_thw[i].prod().item()) // merge for i in range(len(images))
|
||||
]
|
||||
pos_ids, pos_tags = minimax_h3_multi_image_presentation(
|
||||
self.tokenizer,
|
||||
prompt=prompt,
|
||||
image_token_counts=image_token_counts,
|
||||
)
|
||||
pos_hidden = encode_ids(
|
||||
pos_ids,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
)
|
||||
return {
|
||||
"positive": {
|
||||
"hidden_states": pos_hidden,
|
||||
"text_len": int(pos_ids.shape[0]),
|
||||
"text_token_tags": pos_tags,
|
||||
},
|
||||
}
|
||||
|
||||
def _encode_ref2va(self, batch: Req, plan, encode_ids) -> dict:
|
||||
"""Encode the positive ref2va presentation.
|
||||
|
||||
Per condition in order — image i: '<Picture i>: ' label +
|
||||
vision block (prepared reference image); audio j: '<Audio j>: ' label
|
||||
only — then the verbatim prompt.
|
||||
"""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.presentation import (
|
||||
minimax_h3_ref2va_presentation,
|
||||
minimax_h3_ref2va_video_presentation,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.reference_encoding import (
|
||||
minimax_h3_prepared_reference_image,
|
||||
minimax_h3_prepared_reference_videos,
|
||||
minimax_h3_sample_reference_video_frames,
|
||||
)
|
||||
|
||||
prepared_videos = None
|
||||
if any(
|
||||
material.material_chain
|
||||
in ("video.reference_preserve", "video_audio.reference_preserve")
|
||||
for material in plan.materials
|
||||
):
|
||||
prepared_videos = minimax_h3_prepared_reference_videos(batch, plan)
|
||||
video_has_audio: dict[int, bool] = {}
|
||||
for video_index, item in enumerate((prepared_videos or {}).get("videos") or []):
|
||||
if item.get("condition_index") is None:
|
||||
continue
|
||||
if "input_has_audio" not in item:
|
||||
raise ValueError(
|
||||
f"prepared reference video {video_index} is missing "
|
||||
"'input_has_audio'; the canonical minimax_h3 producer must "
|
||||
"supply the audio probe for every video condition"
|
||||
)
|
||||
video_has_audio[int(item["condition_index"])] = bool(
|
||||
item["input_has_audio"]
|
||||
)
|
||||
condition_labels: list[tuple[str, int]] = []
|
||||
counters = {"image": 0, "audio": 0, "video": 0}
|
||||
has_image = False
|
||||
has_video = False
|
||||
for material in plan.materials:
|
||||
if material.material_chain == "image.reference_preserve":
|
||||
counters["image"] += 1
|
||||
condition_labels.append(("image", counters["image"]))
|
||||
has_image = True
|
||||
elif material.material_chain == "audio":
|
||||
counters["audio"] += 1
|
||||
condition_labels.append(("audio", counters["audio"]))
|
||||
elif material.material_chain in (
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
):
|
||||
# A plain video contributes an Audio label only when its
|
||||
# probed source actually has a soundtrack. ``video_audio`` is
|
||||
# an explicit caller promise and remains fail-closed in the
|
||||
# audio stage if its stream is missing.
|
||||
if material.material_chain == "video_audio.reference_preserve":
|
||||
contributes_audio = True
|
||||
else:
|
||||
condition_index = int(material.condition_index)
|
||||
if condition_index not in video_has_audio:
|
||||
raise KeyError(
|
||||
"prepared reference videos carry no "
|
||||
f"'input_has_audio' probe for condition "
|
||||
f"{condition_index}; the canonical minimax_h3 "
|
||||
"producer must supply it"
|
||||
)
|
||||
contributes_audio = video_has_audio[condition_index]
|
||||
if contributes_audio:
|
||||
counters["audio"] += 1
|
||||
condition_labels.append(("audio", counters["audio"]))
|
||||
counters["video"] += 1
|
||||
condition_labels.append(("video", counters["video"]))
|
||||
has_video = True
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"ref2va does not support chain {material.material_chain!r}"
|
||||
)
|
||||
|
||||
pixel_values = None
|
||||
image_grid_thw = None
|
||||
n_image_tokens = None
|
||||
processor = self.processor
|
||||
|
||||
if has_image:
|
||||
prepared = minimax_h3_prepared_reference_image(batch, plan)
|
||||
images = [item["image"] for item in prepared["images"]]
|
||||
proc = processor
|
||||
vision = proc.image_processor(images=images, return_tensors="pt")
|
||||
pixel_values = vision["pixel_values"]
|
||||
image_grid_thw = vision["image_grid_thw"]
|
||||
if int(image_grid_thw.shape[0]) != len(images):
|
||||
raise ValueError(
|
||||
f"expected {len(images)} image grids, got "
|
||||
f"{list(image_grid_thw.shape)}"
|
||||
)
|
||||
merge = int(proc.image_processor.merge_size) ** 2
|
||||
counts = [
|
||||
int(image_grid_thw[i].prod().item()) // merge
|
||||
for i in range(len(images))
|
||||
]
|
||||
# presentation takes an int for one image, a list for several
|
||||
n_image_tokens = counts[0] if len(counts) == 1 else counts
|
||||
|
||||
pixel_values_videos = None
|
||||
video_grid_thw = None
|
||||
video_block_token_counts = None
|
||||
video_block_timestamps = None
|
||||
if has_video:
|
||||
prepared = prepared_videos or minimax_h3_prepared_reference_videos(
|
||||
batch, plan
|
||||
)
|
||||
videos = []
|
||||
sampled_videos = []
|
||||
for item in prepared["videos"]:
|
||||
sampled = minimax_h3_sample_reference_video_frames(item["frames"])
|
||||
videos.append(torch.from_numpy(sampled["frames"]).permute(0, 3, 1, 2))
|
||||
sampled_videos.append(sampled)
|
||||
proc = processor
|
||||
vout = proc.video_processor(
|
||||
videos=videos,
|
||||
do_sample_frames=False,
|
||||
input_data_format="channels_first",
|
||||
return_tensors="pt",
|
||||
)
|
||||
pixel_values_videos = vout["pixel_values_videos"]
|
||||
video_grid_thw = vout["video_grid_thw"]
|
||||
if int(video_grid_thw.shape[0]) != len(videos):
|
||||
raise ValueError(
|
||||
f"expected {len(videos)} video grids, got "
|
||||
f"{list(video_grid_thw.shape)}"
|
||||
)
|
||||
merge = int(proc.image_processor.merge_size) ** 2
|
||||
video_block_token_counts = []
|
||||
video_block_timestamps = []
|
||||
for index, sampled in enumerate(sampled_videos):
|
||||
n_blocks = int(video_grid_thw[index, 0])
|
||||
per_block = (
|
||||
int(video_grid_thw[index, 1])
|
||||
* int(video_grid_thw[index, 2])
|
||||
// merge
|
||||
)
|
||||
timestamps = [float(ts) for ts in sampled["block_timestamps"]]
|
||||
if len(timestamps) != n_blocks:
|
||||
raise ValueError(
|
||||
f"video block count mismatch: processor {n_blocks} vs "
|
||||
f"timestamps {len(timestamps)} for video {index}"
|
||||
)
|
||||
video_block_token_counts.append([per_block] * n_blocks)
|
||||
video_block_timestamps.append(timestamps)
|
||||
|
||||
if has_video:
|
||||
pos_ids, pos_tags = minimax_h3_ref2va_video_presentation(
|
||||
self.tokenizer,
|
||||
prompt=plan.prompt,
|
||||
condition_labels=condition_labels,
|
||||
image_token_count=n_image_tokens,
|
||||
video_block_token_counts=video_block_token_counts,
|
||||
video_block_timestamps=video_block_timestamps,
|
||||
)
|
||||
else:
|
||||
pos_ids, pos_tags = minimax_h3_ref2va_presentation(
|
||||
self.tokenizer,
|
||||
prompt=plan.prompt,
|
||||
condition_labels=condition_labels,
|
||||
image_token_count=n_image_tokens,
|
||||
)
|
||||
pos_hidden = encode_ids(
|
||||
pos_ids,
|
||||
pixel_values=pixel_values,
|
||||
image_grid_thw=image_grid_thw,
|
||||
pixel_values_videos=pixel_values_videos,
|
||||
video_grid_thw=video_grid_thw,
|
||||
)
|
||||
return {
|
||||
"positive": {
|
||||
"hidden_states": pos_hidden,
|
||||
"text_len": int(pos_ids.shape[0]),
|
||||
"text_token_tags": pos_tags,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3TextEncodingStage"]
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.base import PipelineStage
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
StageValidators as V,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.validators import (
|
||||
VerificationResult,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
from ..constants import MINIMAX_H3_SIGMAS_EXTRA_KEY
|
||||
|
||||
|
||||
class MiniMaxH3TimestepPreparationStage(PipelineStage):
|
||||
deduplicated_tensor_tree_output_fields = ("timesteps", "sigmas")
|
||||
deduplicated_extra_tensor_tree_output_keys = (MINIMAX_H3_SIGMAS_EXTRA_KEY,)
|
||||
|
||||
def __init__(self, sigma_shift_scales=None) -> None:
|
||||
super().__init__()
|
||||
# Per-model sigma shift override (model_index.json "_minimax_h3" release
|
||||
# block, sigma_shift_scales): the schedule constants are a MODEL
|
||||
# serving contract — fl2va and ref2va use video 12 / audio 3 by default.
|
||||
self.sigma_shift_scales = sigma_shift_scales
|
||||
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} is a MiniMax H3 contract stage "
|
||||
"and has no implementation yet."
|
||||
)
|
||||
self._generate_sigmas_from_plan(batch, plan)
|
||||
self._publish_native_timestep_state(batch)
|
||||
return batch
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args: ServerArgs):
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is None:
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} is a MiniMax H3 contract stage "
|
||||
"and has no implementation yet."
|
||||
)
|
||||
return (
|
||||
batch.num_inference_steps,
|
||||
plan.flow_shift,
|
||||
plan.audio_flow_shift,
|
||||
plan.default_flow_shift,
|
||||
plan.default_audio_flow_shift,
|
||||
self.freeze_for_dedup(self.sigma_shift_scales),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _publish_native_timestep_state(batch: Req) -> None:
|
||||
sigmas = batch.extra.get(MINIMAX_H3_SIGMAS_EXTRA_KEY)
|
||||
if not isinstance(sigmas, dict):
|
||||
raise ValueError("MiniMax H3 sigma schedules must be a mapping")
|
||||
video_sigmas = sigmas.get("video")
|
||||
audio_sigmas = sigmas.get("audio")
|
||||
if (
|
||||
not isinstance(video_sigmas, list)
|
||||
or not isinstance(audio_sigmas, list)
|
||||
or len(video_sigmas) != len(audio_sigmas)
|
||||
or len(video_sigmas) < 2
|
||||
):
|
||||
raise ValueError(
|
||||
"MiniMax H3 video/audio sigma schedules must be equal-length lists"
|
||||
)
|
||||
batch.sigmas = list(video_sigmas)
|
||||
batch.timesteps = torch.tensor(
|
||||
[1.0 - float(sigma) for sigma in video_sigmas[:-1]],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
def _generate_sigmas_from_plan(self, batch: Req, plan) -> None:
|
||||
"""Generate the fixed per-modality float32 time-shift schedules."""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.time_request import (
|
||||
minimax_h3_time_shift_sigmas,
|
||||
)
|
||||
|
||||
if MINIMAX_H3_SIGMAS_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
requested_num_steps = getattr(batch, "num_inference_steps", None)
|
||||
if requested_num_steps is None:
|
||||
sampling = getattr(batch, "sampling_params", None)
|
||||
requested_num_steps = getattr(sampling, "num_inference_steps", None)
|
||||
if requested_num_steps is None:
|
||||
requested_num_steps = 50
|
||||
if (
|
||||
isinstance(requested_num_steps, bool)
|
||||
or not isinstance(requested_num_steps, int)
|
||||
or requested_num_steps <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"num_inference_steps must be a positive integer, got "
|
||||
f"{requested_num_steps!r}"
|
||||
)
|
||||
|
||||
model_scales = self.sigma_shift_scales
|
||||
if model_scales is not None and not isinstance(model_scales, Mapping):
|
||||
raise ValueError("model sigma_shift_scales must be an object")
|
||||
|
||||
def resolved_scale(
|
||||
*, modality: str, request_value, task_default: float
|
||||
) -> float:
|
||||
value = request_value
|
||||
source = (
|
||||
"request "
|
||||
f"{'flow_shift' if modality == 'video' else 'audio_flow_shift'}"
|
||||
)
|
||||
if value is None and model_scales is not None:
|
||||
value = model_scales.get(modality)
|
||||
source = f"model sigma_shift_scales.{modality}"
|
||||
if value is None:
|
||||
value = task_default
|
||||
source = (
|
||||
"task default "
|
||||
f"{'flow_shift' if modality == 'video' else 'audio_flow_shift'}"
|
||||
)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{source} must be a positive finite number")
|
||||
scale = float(value)
|
||||
if not math.isfinite(scale) or scale <= 0.0:
|
||||
raise ValueError(f"{source} must be a positive finite number")
|
||||
return scale
|
||||
|
||||
scales = {
|
||||
"video": resolved_scale(
|
||||
modality="video",
|
||||
request_value=plan.flow_shift,
|
||||
task_default=plan.default_flow_shift,
|
||||
),
|
||||
"audio": resolved_scale(
|
||||
modality="audio",
|
||||
request_value=plan.audio_flow_shift,
|
||||
task_default=plan.default_audio_flow_shift,
|
||||
),
|
||||
}
|
||||
sigmas: dict[str, list[float]] = {}
|
||||
for modality in ("video", "audio"):
|
||||
sigmas[modality] = minimax_h3_time_shift_sigmas(
|
||||
num_steps=requested_num_steps,
|
||||
shift_scale=scales[modality],
|
||||
)
|
||||
batch.extra[MINIMAX_H3_SIGMAS_EXTRA_KEY] = sigmas
|
||||
|
||||
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check(
|
||||
"num_inference_steps", batch.num_inference_steps, V.positive_int
|
||||
)
|
||||
result.add_check("timesteps", batch.timesteps, V.none_or_tensor)
|
||||
result.add_check("sigmas", batch.sigmas, V.none_or_list)
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: Req, server_args: ServerArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("timesteps", batch.timesteps, [V.is_tensor, V.with_dims(1)])
|
||||
result.add_check("sigmas", batch.sigmas, V.list_not_empty)
|
||||
result.add_check(
|
||||
MINIMAX_H3_SIGMAS_EXTRA_KEY,
|
||||
batch.extra.get(MINIMAX_H3_SIGMAS_EXTRA_KEY),
|
||||
lambda value: isinstance(value, Mapping),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3TimestepPreparationStage"]
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.multimodal_gen.runtime.disaggregation.roles import RoleType
|
||||
from sglang.multimodal_gen.runtime.managers.memory_managers.component_manager import (
|
||||
ComponentUse,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.condition_encoding import (
|
||||
ConditionEncodingStage,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.task_profiles import (
|
||||
MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||
|
||||
|
||||
class MiniMaxH3VisualEncodingStage(ConditionEncodingStage):
|
||||
deduplicated_extra_output_keys = (
|
||||
MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_vae,
|
||||
vae_arch_config,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.video_vae = video_vae
|
||||
self.vae_arch_config = vae_arch_config
|
||||
|
||||
@property
|
||||
def role_affinity(self) -> RoleType:
|
||||
return RoleType.ENCODER
|
||||
|
||||
def component_uses(
|
||||
self, server_args: ServerArgs, stage_name: str | None = None
|
||||
) -> list[ComponentUse]:
|
||||
stage_name = self._component_stage_name(stage_name)
|
||||
return [ComponentUse(stage_name, "video_vae")]
|
||||
|
||||
def build_dedup_fingerprint(self, batch: Req, server_args: ServerArgs):
|
||||
parent_request_id = batch.extra.get("parent_request_id")
|
||||
return (
|
||||
("expanded_outputs", parent_request_id)
|
||||
if parent_request_id is not None
|
||||
else id(batch)
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.material_io import (
|
||||
minimax_h3_cleanup_temp_dirs,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._forward(batch, server_args)
|
||||
except Exception:
|
||||
# No later stage will run after an encoder failure.
|
||||
minimax_h3_cleanup_temp_dirs(batch)
|
||||
raise
|
||||
|
||||
def _forward(self, batch: Req, server_args: ServerArgs) -> Req:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.resolved_plan import (
|
||||
minimax_h3_plan_from_batch,
|
||||
)
|
||||
|
||||
plan = minimax_h3_plan_from_batch(batch)
|
||||
if plan is not None:
|
||||
routed = plan.encoders.get("visual")
|
||||
if not routed:
|
||||
return batch
|
||||
from .replica_broadcast import (
|
||||
minimax_h3_replica_broadcast_error,
|
||||
minimax_h3_replica_broadcast_extra,
|
||||
minimax_h3_replica_ctx,
|
||||
)
|
||||
|
||||
output_keys = self._visual_output_keys(plan, routed)
|
||||
replica_world, replica_rank = minimax_h3_replica_ctx()
|
||||
parallel_encode = replica_world > 1 and bool(self.video_vae.parallel_tiling)
|
||||
owner_exception = None
|
||||
owner_error = None
|
||||
if (parallel_encode or replica_rank == 0) and any(
|
||||
key not in batch.extra for key in output_keys
|
||||
):
|
||||
try:
|
||||
with self.use_declared_component(
|
||||
component_name="video_vae",
|
||||
module=self.video_vae,
|
||||
) as video_vae:
|
||||
assert video_vae is not None
|
||||
self.video_vae = video_vae
|
||||
self._encode_keyframes_from_plan(batch, plan, routed)
|
||||
except Exception as exc:
|
||||
owner_exception = exc
|
||||
owner_error = f"{type(exc).__name__}: {exc}"
|
||||
if parallel_encode:
|
||||
if owner_exception is not None:
|
||||
raise owner_exception
|
||||
else:
|
||||
owner_error = minimax_h3_replica_broadcast_error(owner_error)
|
||||
if owner_error is not None:
|
||||
if owner_exception is not None:
|
||||
raise owner_exception
|
||||
raise RuntimeError(
|
||||
f"MiniMax H3 visual encode failed on rank 0: {owner_error}"
|
||||
)
|
||||
for key in output_keys:
|
||||
minimax_h3_replica_broadcast_extra(batch, key)
|
||||
return batch
|
||||
if (
|
||||
batch.sampling_params is not None
|
||||
and batch.sampling_params.image_path is not None
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"MiniMaxH3VisualEncodingStage direct visual tokenizer encode "
|
||||
"requires a canonical minimax_h3 request (resolved plan); "
|
||||
"legacy image_path-only requests are unsupported."
|
||||
)
|
||||
return batch
|
||||
|
||||
@staticmethod
|
||||
def _visual_output_keys(plan, routed) -> tuple[str, ...]:
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY,
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY,
|
||||
)
|
||||
|
||||
routed_set = set(routed)
|
||||
chains = {
|
||||
material.material_chain
|
||||
for material in plan.materials
|
||||
if material.condition_index in routed_set
|
||||
}
|
||||
keys = []
|
||||
if "image.target_canvas" in chains:
|
||||
keys.append(MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY)
|
||||
if "image.reference_preserve" in chains:
|
||||
keys.append(MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY)
|
||||
if chains & {
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
}:
|
||||
keys.append(MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY)
|
||||
if not keys:
|
||||
raise ValueError("MiniMax H3 visual routing selected no visual materials")
|
||||
return tuple(keys)
|
||||
|
||||
def _encode_keyframes_from_plan(self, batch: Req, plan, routed) -> None:
|
||||
"""Direct keyframe encode: seeded sampled encode_images ->
|
||||
normalized [n,96] cond rows in batch.extra."""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.keyframe_encoding import (
|
||||
minimax_h3_encode_keyframe_cond_rows,
|
||||
minimax_h3_scoped_encode_fp32,
|
||||
)
|
||||
|
||||
materials = [m for m in plan.materials if m.condition_index in set(routed)]
|
||||
chains = {m.material_chain for m in materials}
|
||||
keyframe_materials = [
|
||||
material
|
||||
for material in materials
|
||||
if material.material_chain == "image.target_canvas"
|
||||
]
|
||||
if str(plan.task) == "fl2va":
|
||||
frame_indices = tuple(
|
||||
material.frame_index for material in keyframe_materials
|
||||
)
|
||||
if frame_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES:
|
||||
raise ValueError(
|
||||
"fl2va visual encoding requires an ordered keyframe signature "
|
||||
f"in {MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES!r}, got "
|
||||
f"{frame_indices!r}"
|
||||
)
|
||||
elif keyframe_materials:
|
||||
raise ValueError(
|
||||
f"task {plan.task!r} cannot carry image.target_canvas materials"
|
||||
)
|
||||
if MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
if chains == {"image.reference_preserve"}:
|
||||
with minimax_h3_scoped_encode_fp32(self.video_vae):
|
||||
self._encode_reference_image(batch, plan)
|
||||
return
|
||||
video_chains = {
|
||||
"video.reference_preserve",
|
||||
"video_audio.reference_preserve",
|
||||
}
|
||||
if chains and chains <= {"image.reference_preserve", *video_chains}:
|
||||
# One VAE dtype toggle for both encodes below, not one each.
|
||||
with minimax_h3_scoped_encode_fp32(self.video_vae):
|
||||
if "image.reference_preserve" in chains:
|
||||
self._encode_reference_image(batch, plan)
|
||||
if chains & video_chains:
|
||||
self._encode_reference_video(batch, plan)
|
||||
return
|
||||
unsupported = [
|
||||
m.material_chain
|
||||
for m in materials
|
||||
if m.material_chain != "image.target_canvas"
|
||||
]
|
||||
if unsupported:
|
||||
raise NotImplementedError(
|
||||
"MiniMaxH3VisualEncodingStage direct encode only supports "
|
||||
f"image.target_canvas / image.reference_preserve, got {unsupported}"
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.canvas import (
|
||||
minimax_h3_prepared_keyframes,
|
||||
)
|
||||
|
||||
# Parallel tiling gives each replicated rank complete tiles, then gathers
|
||||
# them before the seeded posterior sample.
|
||||
prepared = minimax_h3_prepared_keyframes(batch, plan)
|
||||
prepared_indices = tuple(prepared.get("semantic_frame_indices") or ())
|
||||
if prepared_indices not in MINIMAX_H3_FL2VA_KEYFRAME_SIGNATURES or len(
|
||||
prepared.get("images") or ()
|
||||
) != len(prepared_indices):
|
||||
raise ValueError(
|
||||
"fl2va visual preparation requires one or two ordered images "
|
||||
"with a supported semantic_frame_indices signature"
|
||||
)
|
||||
encoded = []
|
||||
rows_list = []
|
||||
# One VAE dtype toggle for the whole signature (up to two keyframes),
|
||||
# not one per keyframe.
|
||||
with minimax_h3_scoped_encode_fp32(self.video_vae):
|
||||
for item in prepared["images"]:
|
||||
image = item["image"]
|
||||
width, height = item["canvas_width"], item["canvas_height"]
|
||||
# The encode sampling seed is pinned at 42 (the VAE sample
|
||||
# seed is part of the contract), independent of the
|
||||
# request seed.
|
||||
rows = minimax_h3_encode_keyframe_cond_rows(
|
||||
self.video_vae,
|
||||
image,
|
||||
self.vae_arch_config,
|
||||
)
|
||||
encoded.append(
|
||||
{
|
||||
"rows": rows,
|
||||
"latent_h": height // 16,
|
||||
"latent_w": width // 16,
|
||||
"canvas_height": height,
|
||||
"canvas_width": width,
|
||||
"frame_index": item.get("frame_index"),
|
||||
"resolved_frame_index": item.get("resolved_frame_index"),
|
||||
"condition_index": item.get("condition_index"),
|
||||
}
|
||||
)
|
||||
rows_list.append(rows)
|
||||
rows = rows_list[0] if len(rows_list) == 1 else torch.cat(rows_list, dim=0)
|
||||
first = encoded[0]
|
||||
batch.extra[MINIMAX_H3_KEYFRAME_COND_ROWS_EXTRA_KEY] = {
|
||||
"rows": rows,
|
||||
"latent_h": first["latent_h"],
|
||||
"latent_w": first["latent_w"],
|
||||
"canvas_height": first["canvas_height"],
|
||||
"canvas_width": first["canvas_width"],
|
||||
"keyframes": encoded,
|
||||
"semantic_frame_indices": prepared.get("semantic_frame_indices"),
|
||||
"pixel_frame_indices": prepared.get("pixel_frame_indices"),
|
||||
"frame_count": prepared.get("frame_count"),
|
||||
}
|
||||
|
||||
def _encode_reference_video(self, batch: Req, plan) -> None:
|
||||
"""ref2va video/video_audio encode from shared transformed frames."""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.reference_encoding import (
|
||||
minimax_h3_encode_reference_video_rows,
|
||||
minimax_h3_prepared_reference_videos,
|
||||
)
|
||||
|
||||
if MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
prepared = minimax_h3_prepared_reference_videos(batch, plan)
|
||||
videos = prepared.get("videos")
|
||||
if not isinstance(videos, list) or not videos:
|
||||
raise ValueError(
|
||||
"prepared reference videos payload must carry a non-empty "
|
||||
f"'videos' list, got {videos!r}"
|
||||
)
|
||||
entries = []
|
||||
for item in videos:
|
||||
rows, latent_t, latent_h, latent_w = minimax_h3_encode_reference_video_rows(
|
||||
self.video_vae,
|
||||
item["frames"],
|
||||
self.vae_arch_config,
|
||||
)
|
||||
entries.append(
|
||||
{
|
||||
"rows": rows,
|
||||
"latent_t": latent_t,
|
||||
"latent_h": latent_h,
|
||||
"latent_w": latent_w,
|
||||
"condition_index": int(item["condition_index"]),
|
||||
"material_chain": str(item["material_chain"]),
|
||||
}
|
||||
)
|
||||
for item in videos:
|
||||
# Text and visual encoding are the only RGB-frame consumers. Drop
|
||||
# the large request-local arrays as soon as both have completed.
|
||||
item.pop("frames", None)
|
||||
payload = dict(entries[0])
|
||||
payload["videos"] = entries
|
||||
batch.extra[MINIMAX_H3_REFERENCE_VIDEO_ROWS_EXTRA_KEY] = payload
|
||||
|
||||
def _encode_reference_image(self, batch: Req, plan) -> None:
|
||||
"""ref2va reference image encode: cap_resize (intrinsic
|
||||
geometry) + the verified keyframe recipe; rows use the image's OWN
|
||||
latent grid, not the target canvas."""
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.constants import (
|
||||
MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.keyframe_encoding import (
|
||||
minimax_h3_encode_keyframe_cond_rows,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.pipelines_core.stages.model_specific_stages.minimax_h3.reference_encoding import (
|
||||
minimax_h3_prepared_reference_image,
|
||||
)
|
||||
|
||||
if MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY in batch.extra:
|
||||
return
|
||||
prepared = minimax_h3_prepared_reference_image(batch, plan)
|
||||
entries = []
|
||||
for item in prepared["images"]:
|
||||
image = item["image"]
|
||||
rows = minimax_h3_encode_keyframe_cond_rows(
|
||||
self.video_vae,
|
||||
image,
|
||||
self.vae_arch_config,
|
||||
)
|
||||
width, height = image.size
|
||||
entries.append(
|
||||
{
|
||||
"rows": rows,
|
||||
"latent_h": height // 16,
|
||||
"latent_w": width // 16,
|
||||
"condition_index": int(item["condition_index"]),
|
||||
"material_chain": "image.reference_preserve",
|
||||
}
|
||||
)
|
||||
payload = dict(entries[0]) # single-image consumers keep the keys
|
||||
payload["images"] = entries
|
||||
batch.extra[MINIMAX_H3_REFERENCE_IMAGE_ROWS_EXTRA_KEY] = payload
|
||||
|
||||
|
||||
__all__ = ["MiniMaxH3VisualEncodingStage"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user