<img alt="" src="https://secure.insightful-enterprise-intelligence.com/783141.png" style="display:none;">

NVIDIA B300s are coming to Hyperstack — On-Demand in August, reserved private clusters in Q4

alert

We’ve been made aware of a fraudulent website impersonating Hyperstack at hyperstack.my.
This domain is not affiliated with Hyperstack or NexGen Cloud.

If you’ve been approached or interacted with this site, please contact our team immediately at support@hyperstack.cloud.

close

publish-dateDecember 2, 2025

5 min read

Updated-dateUpdated on 26 Aug 2026

Run DeepSeek OCR on Hyperstack with your Own UI

Written by

Hitesh Kumar

Hitesh Kumar

Share this post

TABLE OF CONTENTS

NVIDIA H100 GPUs On-Demand

Sign up/Login

Key Takeaways

  • DeepSeek-OCR is a multimodal OCR model designed to extract both text and document structure from images and PDFs.

  • The setup uses a Hyperstack GPU virtual machine to run DeepSeek-OCR in a private, high-performance environment.

  • The model combines a vision encoder and a language decoder to handle complex layouts such as tables and multi-column documents.

  • Deployment involves cloning the DeepSeek-OCR repository, installing Python dependencies, and configuring the runtime environment.

  • A Gradio-based web interface allows users to upload documents and view OCR results in structured Markdown output.

  • The deployed OCR service can be extended into APIs or integrated into document processing and RAG workflows.

Take Control of Your Own OCR Workflow with DeepSeek-OCR and Hyperstack

Optical Character Recognition (OCR) is the process of recognising and extracting text from a source like images or PDFs using just the visual field - it's what we do when we read!

Methods for performing OCR have exited for a while but in the past few years (or even months rather), transformer-based models have become incredibly competent at it. DeepSeek, one of the world's leading AI foundation model labs, have released their DeepSeek-OCR 3B parameter model for quickly and easily creating your own OCR workflows.

deepseek

Why is it harder to run than other DeepSeek models?

You might be used to running other AI models, like DeepSeek's LLMs, which are often available via a simple API call or a straightforward Python library like transformers. We've even made tutorials in the past that you can follow to get DeepSeek V3. DeepSeek-OCR is a bit more hands-on because it's not just a language model; it's a specialised multi-modal system.

It essentially has two parts: a sophisticated vision encoder that sees and understands the layout of a page (just like our eyes), and a 3-billion-parameter language decoder that reads and interprets the text from that visual information. This two-stage process is what makes it so powerful, but it also requires a more complex stack of software to run efficiently.

The setup in this guide uses vLLM, a high-throughput serving engine, to get the best possible performance. This is what adds most of the setup steps - we need to install a particular version of it along with dependencies like flash-attn. It's this requirement for a high-performance, GPU-accelerated serving environment that makes it more complex than a simple pip install package, but the payoff in speed and accuracy is well worth it.

How good is DeepSeek-OCR? 

In short: it's exceptionally good. It represents the current state-of-the-art for open-source OCR in its size group, especially when it comes to understanding real-world, complex documents.

Where traditional OCR tools might just extract a "wall of text" that loses all formatting, DeepSeek-OCR understands the structure of the document. This is its key advantage. It excels at:

  • Complex Layouts: Accurately reading multi-column articles, magazine pages, and scientific papers.

  • Tables: It doesn't just see text in a table; it understands the table's rows and columns and formats the output (as markdown) to match.

  • Mixed Content: It's highly adept at handling pages with a mix of text, code blocks, and even mathematical equations.

Because it outputs structured markdown, you're not just getting the raw text; you're getting the document's semantic structure. This makes its output immediately useful for feeding into other systems, like a RAG pipeline or a summarisation model. For its 3B-parameter size, it hits a perfect sweet spot of being incredibly accurate while still being fast enough to interpret huge documents on a single H100 GPU.

How to set up DeepSeek-OCR on your own Hyperstack VM, step-by-step

We'll take you through the whole process from start to end to get a really simple and basic OCR workflow running on your own Hyperstack VM. 

Step 0: Getting a Hyperstack VM

This guide assumes you've just spun up a new Linux VM on our platform and can access it via SSH. If you haven't done this before, please see our getting started guide in our documentation.

Step 1: Clone the DeepSeek-OCR repo 

# Clone the DeepSeek-OCR repository
git clone https://github.com/deepseek-ai/DeepSeek-OCR.git

Step 2: Install UV (the package manager):

curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env

Step 3: Create a python virtual environment:

uv venv deepseek-ocr --python 3.12.9
source deepseek-ocr/bin/activate

Step 4: Install vLLM and other requirements

cd DeepSeek-OCR

# Get vllm whl
wget https://github.com/vllm-project/vllm/releases/download/v0.8.5/vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
unzip vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl -d vllm-0.8.5+cu118-whl

# Install requirements
uv pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118
uv pip install vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
uv pip install -r requirements.txt
uv pip install flash-attn==2.7.3 --no-build-isolation
uv pip install uvicorn fastapi gradio --upgrade
uv pip install transformers==4.57.1 --upgrade

This step may take a while, there are a lot of dependencies!

Step 5: Download the Python code

main.py 

This is a standalone python file that sets up the webserver and hosts it on your VM. We recommend you have a quick read through before you attempt to run it, just to familiarise yourself with what it does (more on this later).

Step 6: Get the code into your VM:

# Create the "web" dir and put main.py in there
cd DeepSeek-OCR-master/DeepSeek-OCR-vllm
mkdir -p web

cat <<EOF > web/main.py
<paste the contents of main.py here>
EOF

You can alternatively use some editor like nano or vim, or SSH into this VM from a more interactive source like VSCode or similar to make this part easier. 

Step 7: Start the server and access via your browser

# Start the server
uvicorn web.main:app --host 0.0.0.0 --port 3000

You should now be able to navigate to the UI by going to http://<your-VMs-ip>:3000, and interact with the UI! 

NOTE: Remember to open port 3000 for inbound TCP traffic via your VM's firewall on Hyperstack! For more info on this, see our documentation here 

Once loaded, It should look something like this:

start the server

In this simple, barebones UI, you can upload PDFs or images and DeepSeek-OCR will automatically run on them.

The results will be visible in the lower box, with the option to see (and download) the labelled input and the extracted text in markdown format. 

To re-run, simple delete the existing input and upload something new!

Here's an example of an example PDF article output from DeepSeek-OCR:

deepseek ocr

Troubleshooting

As stated, this is a very minimal, quickly-put-together UI, and hence is not maintained and updated by Hyperstack, and is certainly not bug-free! However, feel free to modify the code the main.py file to solve any issues or add any features you like.

One bug we are aware of in our early testing is the UI's inability to replace old inputs when new ones are uploaded. In this case, simply Ctrl+C to terminate the server and re-run the same uvicorn command - this and a reload of the web page will then start a fresh instance of the UI with the issue no longer being present. 

What's Next?

Congratulations! You've now got your own private, high-performance OCR server running. This Gradio UI is a fantastic sandbox for testing, but the real power comes from what you can build on top of it.

The most logical next step is to adapt the web/main.py file. Instead of launching a Gradio UI, you could modify it to create a simple, robust REST API endpoint using FastAPI. Imagine an endpoint where you can POST an image or PDF file and get a clean JSON response containing the extracted markdown.

Once you have that API, the possibilities are endless:

  • Build a RAG Pipeline: This is the big one. You can now programmatically feed your entire library of PDFs and documents through this API, storing the clean markdown output in a vector database.

  • Create a "Chat with your Docs" App: Combine your new OCR API with a conversational LLM (like DeepSeek-LLM) to build a powerful application that lets you ask questions about your documents.

  • Automate Data Entry: Create a workflow that watches a specific folder or email inbox, runs any new attachments through your OCR API, and then parses the structured output to populate a database or spreadsheet.

You've done the hard part by setting up the core engine. Now you can use your Hyperstack VM as a stable, private microservice to power all kinds of intelligent document-processing workflows.

Launch Your VM today and Get Started with Hyperstack!

FAQs

What type of model is DeepSeek-OCR?

DeepSeek-OCR is a multimodal model combining vision and language understanding, designed to extract text and structure from documents efficiently.

What format does DeepSeek-OCR output?

It outputs structured markdown that preserves tables, layout, and semantic information, making it ready for downstream processing or RAG pipelines.

Which engine is used for high-throughput serving?

vLLM is used as a high-throughput serving engine, optimised for GPU acceleration to deliver fast, efficient OCR performance.

Which package manager is required for setup?

The setup requires UV, a modern package manager, to create virtual environments and install all dependencies reliably on Hyperstack.

Subscribe to Hyperstack!

Enter your email to get updates to your inbox every week

Get Started

Ready to build the next big thing in AI?

Sign up now
Talk to an expert

Share On Social Media

Related content

Stay updated with our latest articles.

tutorials Tutorials link

Deploying Qwen3.8 Max: A Guide to Multi-Node GPU Cloud Inference

Qwen3.8 Max is a 2.4 trillion parameter mixture-of-experts ...

Qwen3.8 Max is a 2.4 trillion parameter mixture-of-experts model from the Qwen team, and 95 billion of those parameters are awake for any one token. Alibaba put the hosted version in front of developers on 3 August 2026 and published the weights shortly afterwards, which is the first time a Qwen Max class model has been released for anyone to run on their own hardware. The FP8 checkpoint is 2.50 TB across 213 safetensors shards, so a Qwen3.8 Max deployment is a multi-node exercise before it is anything else.

This guide deploys it on Hyperstack across eight nodes of eight NVIDIA H100 80GB PCIe GPUs: eight virtual machines, 64 GPUs in total, running SGLang with tensor parallel 16 by pipeline parallel 4. Every figure below comes from one session on 12 August 2026 that pulled 2.50 TB of weights, reached a live OpenAI-compatible endpoint in under six minutes of engine start-up, and then answered chat, reasoning, tool-calling and concurrency workloads with zero failed requests across the sweep.

There is one thing worth settling before the hardware. The hosted Qwen3.8 Max API does not publish an activated parameter count, which leaves anyone modelling cost to guess at the compute behind a token. The open checkpoint states it in config.json: 95 billion active, ten routed experts of 512 plus one shared expert, 92 layers. That is what makes a Qwen3.8 Max deployment something you can size and price rather than estimate. If you have followed our Kimi K3 multi-node deployment, this is the same class of exercise with a different bottleneck, and the reasons are arithmetic rather than memory.

Inside Qwen3.8 Max: 2.4 Trillion Parameters, 95 Billion Awake

Qwen3.8 Max is a sparse model with a hybrid attention stack. Both halves of that sentence change how you deploy it. Take them one at a time before booking any hardware.

The Qwen3.8 Max FP8 checkpoint

The numbers that decide the cluster, taken from the published model card and the config.json inside the FP8 repository.

2.4T total parameters95B active512 experts10 routed plus 1 shared92 layersGated DeltaNet plus gated attentionBlock FP8 128 by 128262,144 token context
CHECKPOINT
2.50 TB
213 safetensors shards in the FP8 repository
QUANTISATION
Block FP8
fine-grained, block size 128 by 128, dynamic activation scaling
LAYERS
92
69 Gated DeltaNet and 23 gated full attention, one in four
HIDDEN SIZE
8,192
head dimension 256, 64 query heads, 4 key-value heads
EXPERTS
512 per layer
ten routed and one shared awake per token
CONTEXT
262,144 tokens
native, and documented as extensible to 1,010,000

Sources: the Qwen3.8-2.4T-A95B-FP8 and the Qwen3.8-2.4T-A95B model cards. The licence on both repositories is named qwen3.8-max, which is the clearest signal that the open weights and the hosted flagship are the same model.

Sparsity Is What Makes 2.4 Trillion Parameters Servable

Every parameter has to be resident in GPU memory, because the router picks its experts per token and per layer, and there is no useful way to predict which ones. What sparsity buys is compute rather than capacity. The forward pass touches 95 billion parameters, so the cluster is sized by the weight of the model and the speed is set by a far smaller number.

What one token wakes up

The router selects ten of 512 experts in each layer, and one shared expert always runs alongside them.

Routertop-k = 10512 routed experts, one layer10 awakeShared expertalways on2,400,000,000,000 parameters resident95,000,000,000 of them do the work on any one token, which is 3.96 per cent3.96%

Drawn from the layer configuration in the FP8 config.json: num_experts 512, num_experts_per_tok 10, plus a shared expert of intermediate size 2,048.

Resident against active

The full model has to fit. Only a small slice of it does the work on any given token.

Chart: 2,400 billion parameters resident on the cluster against 95 billion awake per token, which is 3.96 per cent, and eleven of 512 experts per layer.

Computed from the published parameter counts: 95 billion of 2,400 billion is 3.96 per cent, and eleven experts of 512 is 2.15 per cent.

The Hybrid Attention Stack Sets the Concurrency Limit

The 92 layers are not uniform. Three out of every four use Gated DeltaNet, a linear attention layer that carries a fixed-size recurrent state per sequence. The fourth is gated full attention with a conventional key-value cache. The configuration expresses this as full_attention_interval: 4, which gives 69 linear layers and 23 full attention layers.

One block, repeated 23 times

Three Gated DeltaNet layers, then one gated full attention layer, all the way to 92.

Gated DeltaNetlinear attentionlayer 1Gated DeltaNetlinear attentionlayer 2Gated DeltaNetlinear attentionlayer 3Gated Attentionfull attentionlayer 4Fixed-size recurrent state per sequence128 value heads, 16 key heads, head dim 128. Cost does not grow with context length.KV cache64 query heads, 4 KV heads

Layer types read directly from the layer_types array in config.json: 69 entries of linear_attention and 23 of full_attention.

The practical consequence arrives at serving time. A conventional transformer runs out of concurrency when the key-value cache fills, so the limit moves with context length. Here the recurrent state is a fixed cost per sequence regardless of how long that sequence becomes, and it is the larger of the two pools. When the engine reported its capacity, the ceiling on concurrent requests came from the recurrent state pool rather than from the key-value cache, and that shapes the whole throughput picture further down.

What the Published Scores Say

Qwen positions this release around long-horizon agentic work, coding and research rather than raw single-turn question answering, and the published headline scores follow that emphasis.

Qwen3.8 Max, published benchmark scores

Three results quoted on the model card, spanning research work, terminal use and software engineering. Higher is better on each.

Chart: published Qwen3.8 Max scores, PaperBench 93.0, Terminal Bench 2.1 86.6 and SWE-bench Pro 67.7.

Scores as published on the Qwen3.8-2.4T-A95B model card, quoted for context on what the model is aimed at rather than reproduced here.

📘

Two repositories, one model. The BF16 checkpoint is roughly 4.45 TiB and the FP8 checkpoint is 2.27 TiB, which the vLLM recipe reports as needing three to six nodes and two to four nodes respectively. This deployment uses FP8, and the quantisation is native to the checkpoint rather than applied at load time.

The Qwen3.8 Max Deployment Architecture: Why 64 NVIDIA H100 GPUs

Sizing a cluster for a 2.50 TB checkpoint looks like a division problem and is not one. Memory rules out the small clusters, and then a second constraint rules out two more that memory was perfectly happy with. Getting to the right answer takes both filters, in that order.

The checkpoint against the cluster

Eight nodes of eight NVIDIA H100 80GB cards give 5,120 GB of GPU memory. The weights take a little under half of it.

node 0node 1node 2node 3node 4node 5node 6node 72.50 TB of weights2.62 TB free48.8%39.1 GB of weights per GPU, leaving roughly 40 GB for the caches, the graphs and the running batchSeven nodes leave 35.4 GB free and still fit. They are ruled out by arithmetic rather than by memory.

Checkpoint size from the Qwen3.8-2.4T-A95B-FP8 repository: 213 shards totalling 2.50 TB.

The First Filter Is Memory, and It Is the Forgiving One

Weights are only part of what a GPU has to hold. The recurrent state pool, the key-value cache and the captured CUDA graphs all come out of the same 80 GB, and whatever is left is what serves the running batch. We sized this deployment against a floor of roughly 25 GB free per GPU after weights, which turns the question from a theoretical lower bound into a usable one.

Four nodes fail this outright. The weights alone would need 78.1 GB of an 80 GB card, which leaves nothing for anything else. Five nodes clear the weights and then fall under the free-memory floor. Six and seven nodes pass comfortably.

The Second Filter Is Divisibility, and It Is the Strict One

A parallel placement is only legal if the numbers divide. The pipeline degree has to divide the layer count evenly, and the tensor degree has to divide the attention head count and the expert count. Applying that to six and seven nodes removes both, even though both fit the weights with room to spare.

Memory headroom is not a feasibility test

Two cluster sizes hold the weights comfortably and still cannot be configured at all.

4 nodes32 GPUs78.1 GBweights per GPUover budgetWeights alone need 78.1 GB of an 80 GB card. Nothing left to serve with.6 nodes48 GPUs52.1 GBweights per GPUno legal splitFits with 27.9 GB free, but tensor parallel 24 or 12 divides neither 64 heads nor the layer countcleanly.7 nodes56 GPUs44.6 GBweights per GPUno legal splitFits with 35.4 GB free. Tensor parallel 28 and 14 do not divide 64 query heads.8 nodes64 GPUs39.1 GBweights per GPUtensor parallel 16 x pipeline 4Fits with 40.9 GB free, and every count that matters divides cleanly.

Independently re-derived by the model itself during the tool-calling run further down, which rejected six and seven nodes for the same reason.

The arithmetic behind that is short enough to check by hand. Qwen3.8 Max has 92 layers, and 92 factorises as 2 squared times 23, so a pipeline degree has to be one of 1, 2, 4, 23, 46 or 92. It also has to divide the GPU count. On 48 GPUs that leaves pipeline degrees of 1, 2 and 4, giving tensor degrees of 48, 24 and 12. None of those divides 64 query heads. On 56 GPUs the tensor degrees are 56, 28 and 14, and again none divides 64. Six and seven node clusters are not tight, they are impossible.

Every count that has to divide, at tensor parallel 16

The chosen placement splits each of these cleanly, which is what makes it legal.

What is being split Value in the config Divided by 16 Result
Query heads 64 64 / 16 4 per GPU
Gated DeltaNet key heads 16 16 / 16 1 per GPU
Gated DeltaNet value heads 128 128 / 16 8 per GPU
Routed experts 512 512 / 16 32 per GPU
Hidden size 8,192 8,192 / 16 512 per GPU
Layers, across pipeline stages 92 92 / 4 23 per stage

Values read from config.json in the FP8 repository. Key-value heads are 4, which is smaller than the tensor degree, so the engine replicates them across the group. That is ordinary behaviour for grouped-query attention at high tensor parallelism, and it means the key-value cache does not shrink as the tensor degree rises.

Why Pipeline Parallel 4 and Tensor Parallel 16

Three placements are legal on 64 GPUs: tensor 64 by pipeline 1, tensor 32 by pipeline 2, and tensor 16 by pipeline 4. The choice between them is about where the traffic goes.

A tensor-parallel group communicates on every layer, so its members want to be as close together as possible. At tensor 64 the group spans all eight machines, and every collective in every layer crosses the network across 64 ranks. At tensor 16 the group spans two machines, and the four pipeline stages exchange activations only at the three stage boundaries. Twenty-three layers per stage divides evenly. The SGLang cookbook lists a verified four-node NVIDIA H200 placement for this same FP8 checkpoint, which is the same weights on cards large enough to need half as many GPUs. Our guide to tensor and pipeline parallelism covers the general trade-off between the two axes if you want the background.

Tensor parallel 16 by pipeline parallel 4, over eight nodes

Each pipeline stage owns 23 layers and runs across two machines. Every GPU in the cluster holds a slice of every expert it is responsible for.

STAGE 0
23 layers
0 to 22
NODE 0
01234567
NODE 1
01234567
TP 16
STAGE 1
23 layers
23 to 45
NODE 2
01234567
NODE 3
01234567
TP 16
STAGE 2
23 layers
46 to 68
NODE 4
01234567
NODE 5
01234567
TP 16
STAGE 3
23 layers
69 to 91
NODE 6
01234567
NODE 7
01234567
TP 16
NODES
8
GPUS
64
LAYERS PER STAGE
23
PER TENSOR GROUP
16 GPUs

The exact shape launched in this deployment: --tp-size 16 --pp-size 4 --nnodes 8.

💡

Pipeline parallelism turns off speculative decoding. The config declares mtp_num_hidden_layers: 1, so a multi-token prediction head exists in the checkpoint. With a pipeline degree above one the engine does not use it, and the startup log states plainly that pipeline parallelism is incompatible with the overlap schedule. On hardware where the whole model fits in one tensor group, that head becomes available again.

What the Fabric Decides About Your Launch Flags

The launch command carries four settings that exist because of how the GPUs are wired, not because of anything about Qwen3.8 Max. Reading the topology first makes all four obvious.

nvidia-smi topo -m, one node, abridgedOUTPUT
      GPU0  GPU1  GPU2  GPU3  GPU4  GPU5  GPU6  GPU7  NIC0
GPU0   X    NV12  PHB   PHB   PHB   PHB   PHB   PHB   PHB
GPU1  NV12   X    PHB   PHB   PHB   PHB   PHB   PHB   PHB
GPU2  PHB   PHB    X    NV12  PHB   PHB   PHB   PHB   PHB
GPU3  PHB   PHB   NV12   X    PHB   PHB   PHB   PHB   PHB
...
NIC0  PHB   PHB   PHB   PHB   PHB   PHB   PHB   PHB    X

  NV#  = Connection traversing a bonded set of # NVLinks
  PHB  = Connection traversing PCIe as well as a PCIe Host Bridge

How the cards inside one node are joined

NVLink bridges pair the cards. Every other path between them runs through the PCIe host bridge.

GPU 0GPU 1NV12GPU 2GPU 3NV12GPU 4GPU 5NV12GPU 6GPU 7NV12PCIe host bridge, every path that is not a bridged pairA tensor-parallel group of 16 spans two machines, so the engine selects the portable collective pathCustomAllreduce and multimem all-gather are single-node optimisations and switch themselves off

The NVIDIA H100 80GB PCIe flavour carries three NVLink bridges per card at 600 GB/s bidirectional, which is what nvidia-smi reports as NV12 between paired devices.

A tensor-parallel group of 16 covers eight cards on one machine and eight on another, so the group is a multi-node group by definition. Two single-node optimisations then switch themselves off, and the engine says so during startup.

Startup, the collective path being chosenOUTPUT
[18:15:14 PP0 TP0] Init torch distributed begin.
[18:15:40 PP0 TP0] CustomAllreduce is disabled because this process group
                   spans across nodes.
[18:15:40 PP0 TP0] Init torch distributed ends. elapsed=26.60 s, mem usage=0.47 GB
[18:15:46 PP0 TP0] multimem all-gather disabled because the TP group spans
                   across nodes.

Neither line calls for action. Both are the engine selecting the portable path because the fast path is only correct within one machine. What does call for action is telling the collectives which interface to use, because a multi-homed node will otherwise pick the wrong one and the rendezvous will not complete.

The four fabric settings, and why each one is there

These belong in the container environment rather than in the server arguments.

Setting Value used Why
NCCL_SOCKET_IFNAME ens6 Names the interface holding the private 10.x address, so collectives use the private network rather than the public one.
GLOO_SOCKET_IFNAME ens6 The rendezvous itself runs over Gloo and needs the same instruction separately.
NCCL_IB_DISABLE 1 This flavour communicates between nodes over Ethernet, so InfiniBand discovery is skipped rather than attempted and timed out.
SGLANG_HOST_IP the private address of this node Each rank advertises the address its peers can reach, which is the private one and never the floating IP.

The private network is reachable only inside your Hyperstack environment, which is why the firewall rule in Step 2 can open the whole 10.0.0.0/8 range to itself without exposing anything.

📘

Tensor parallel 16 is a consequence, not a preference. The group is 16 wide because 80 GB cards need 64 of them to hold 2.50 TB, and 64 GPUs admit only three legal splits. Card size is what moves this number, and the cost section prices that out.

How to Deploy Qwen3.8 Max on Hyperstack, Step by Step

Nine steps take a Qwen3.8 Max deployment from an empty account to an endpoint answering requests. Everything up to Step 5 is ordinary virtual machine work repeated eight times. The launch itself is Steps 6 and 7, and Step 7 is where the flags specific to this model appear.

1
Create eight virtual machines
One environment, one flavour, one image, one SSH key.
2
Open the three ports that matter
Two locked to your own address, one open inside the private range.
3
Point the model cache at the ephemeral disk
The root disk holds 96 GB and the checkpoint is 2.50 TB.
4
Pull the serving image and the weights
213 shards on all eight nodes, in parallel.
5
Confirm every node before you commit to a launch
Twenty seconds of checks protect a 64-rank rendezvous.
6
Set the launch environment
Rank, private address, head address, interface.
7
Launch all eight ranks
The same command everywhere, with the rank changing.
8
Watch the start-up
Five memory checkpoints tell you it is going well.
9
Send the first request
The endpoint speaks the OpenAI chat completions API.

Step 1: Create Eight Virtual Machines

From the Hyperstack dashboard, open the virtual machines page and start a new deployment. The getting started guide covers the account setup if this is a first deployment.

Deploying a new virtual machine from the Hyperstack dashboard

Start from the virtual machines page and select deploy a new virtual machine.

Four choices matter, and one of them matters more than the rest.

What to select, and why

The environment field is the one that quietly decides whether this cluster can form at all.

Field Selection Why it matters
Environment One environment for all eight Nodes share a private network only within an environment. Split them and the ranks cannot reach each other.
Flavour 8x NVIDIA H100 80GB PCIe Eight nodes of eight cards give the 64 GPUs the checkpoint needs. Browse the full list in the flavour reference.
Image Ubuntu Server 24.04 LTS R570 CUDA 12.8 with Docker Driver, CUDA and container runtime arrive matched to the serving image, so there is no driver work at all.
SSH key A key already imported to the account Import it first from the key pairs page. A machine created without one cannot be reached.
Floating IP Enabled You need to reach each node once to set it up, and the head node again to serve traffic.

Name the machines predictably, for example q38-0 through q38-7. Rank 0 becomes the head node and the endpoint you eventually call.

Selecting the Ubuntu 24.04 CUDA 12.8 with Docker image

The image picker. Choosing the CUDA and Docker variant removes every driver step from this deployment.

💡

Spot pricing changes the arithmetic on a cluster this size. An eight-node deployment costs eight times whatever one node costs. Spot virtual machines put NVIDIA H100 80GB PCIe at $2.00 per GPU hour on the published pricing, which is $16.00 per node and $128.00 per hour for this cluster, billed by the minute.

Step 2: Open the Three Ports That Matter

A multi-node deployment needs the nodes to talk to each other freely and needs you to talk to exactly one of them. Attach these firewall rules to every machine.

Firewall rules for the cluster

Two rules scoped to your own address, and one scoped to the private range.

Direction Protocol and port Source Purpose
Ingress TCP 22 your address /32 SSH for setup and launch
Ingress TCP 30000 your address /32 The inference endpoint, on the head node
Ingress TCP 1 to 65535 10.0.0.0/8 Rendezvous and collectives between ranks

The third rule looks broad and is not: 10.0.0.0/8 is private address space reachable only from inside your environment. The rendezvous port, the collective ports and the pipeline transfers all live in there.

Step 3: Point the Model Cache at the Ephemeral Disk

Once the machines are active, connect to each one and take stock. The root disk is 96 GB, which is not where 2.50 TB of weights is going. This flavour ships an ephemeral NVMe disk mounted at /ephemeral, and that is the destination.

Terminal, all eight nodes: 1 of 2, taking stockSHELL
# From your workstation, once the eight virtual machines are ACTIVE
ssh -i ~/.ssh/[YOUR KEY] ubuntu@[NODE PUBLIC IP]

# Confirm what you are standing on before anything else
nvidia-smi --query-gpu=index,name,memory.total --format=csv
ip -o -4 addr show | awk '{print $2, $4}' | grep ' 10\.'
df -h / /ephemeral
Expected output, node 0: the GPU and interface linesOUTPUT
index, name, memory.total [MiB]
0, NVIDIA H100 PCIe, 81559 MiB
1, NVIDIA H100 PCIe, 81559 MiB
...
7, NVIDIA H100 PCIe, 81559 MiB

ens6 10.0.0.210/24

Every card reports 81,559 MiB and the private interface is ens6, holding a 10.x address. The third command is the one that decides where the weights go: the root disk is 96 GB, so /ephemeral has to take the 2.50 TB checkpoint. Confirm it has the room before starting the download.

Terminal, all eight nodes: 2 of 2, preparing the cacheSHELL
# The root disk is 96 GB and the checkpoint is 2.50 TB, so the cache
# has to live on the ephemeral NVMe disk. Do this on all eight nodes.
sudo mkdir -p /ephemeral/hf
sudo chown -R ubuntu:ubuntu /ephemeral/hf

python3 -m pip install -q --break-system-packages huggingface_hub hf_transfer

Step 4: Pull the Serving Image and the Weights

The container image and the checkpoint can be fetched at the same time, and on eight nodes both should be started in the background so a dropped SSH session does not take the download with it. The image comes from the SGLang project and carries the kernels this model needs.

Terminal, all eight nodes: the serving imageSHELL
# Pull the serving image in the background while the weights download.
# Images stay on the root disk; only the checkpoint goes to /ephemeral.
nohup docker pull lmsysorg/sglang:qwen38 > /tmp/pull.log 2>&1 &

The weights need a Hugging Face token with access to the Qwen3.8-2.4T-A95B-FP8 repository. Create one from your account settings and keep it out of your shell history.

Terminal, all eight nodes: 2.50 TB of weightsSHELL
# 2.50 TB across 213 safetensors shards. hf_transfer keeps the NVMe disk fed;
# 32 workers saturated the link on our nodes without stalling.
export HF_HOME=/ephemeral/hf
export HF_HUB_ENABLE_HF_TRANSFER=1
export HF_TOKEN=[YOUR HUGGING FACE TOKEN]

nohup python3 -c "
from huggingface_hub import snapshot_download
snapshot_download('Qwen/Qwen3.8-2.4T-A95B-FP8', max_workers=32)
" > /tmp/dl.log 2>&1 &
📘

Every node downloads the whole checkpoint. There is no sharding of the download by rank. Each node reads the shards it needs at load time and ignores the rest, so all eight machines pull 2.50 TB and the cluster moves 20 TB in total. This is the longest step in the deployment by a wide margin, and it is the one to start first.

Step 5: Confirm Every Node Before You Commit to a Launch

A 64-rank job starts by having all 64 ranks meet each other. Every rank waits for the others, so the cluster is only as ready as its least ready node. Checking takes about twenty seconds per machine, and it belongs immediately before the launch rather than earlier in the session.

Terminal, all eight nodes: the shard countSHELL
# Run this on every node and read the number, not the vibe. A shard that is
# still a .incomplete temporary file fails the launch twenty minutes in,
# after the rendezvous has already committed the other 63 ranks.
SHARDS=$(find /ephemeral/hf -name '*.safetensors' | wc -l)
PARTIAL=$(find /ephemeral/hf -name '*.incomplete' | wc -l)
echo "$(hostname): $SHARDS/213 shards, $PARTIAL incomplete"
What a ready cluster looks likeOUTPUT
q38-0: 213/213 shards, 0 incomplete
q38-1: 213/213 shards, 0 incomplete
q38-2: 213/213 shards, 0 incomplete
q38-3: 213/213 shards, 0 incomplete
q38-4: 213/213 shards, 0 incomplete
q38-5: 213/213 shards, 0 incomplete
q38-6: 213/213 shards, 0 incomplete
q38-7: 213/213 shards, 0 incomplete

The shard count is the check that pays for itself most often, because a download that stopped short looks completely normal until the engine reaches the missing file twenty minutes into loading. Three more checks round it out.

Terminal, all eight nodes: the rest of the gateSHELL
# One last look at every node, immediately before the launch rather than
# minutes before it. Twenty seconds here protects a 64-rank rendezvous.
timeout 25 nvidia-smi -L | grep -c '^GPU'          # expect 8
nvidia-smi --query-gpu=ecc.mode.current --format=csv,noheader | sort -u
ip -o -4 addr show | awk '$4 ~ /^10\./ {print $2; exit}'
find /ephemeral/hf -name '*.safetensors' | wc -l   # expect 213

What each check is protecting

Four cheap reads, each standing in front of a failure that would otherwise appear much later and cost much more.

Check Expected What it prevents
nvidia-smi -L returns within a timeout 8 GPUs A node that answers slowly holds up the whole rendezvous.
ecc.mode.current Enabled on all cards Error correction is what turns a marginal memory cell into a logged event rather than a quietly wrong number inside 2.50 TB of weights.
A live 10.x interface One name, for example ens6 NCCL_SOCKET_IFNAME has to name an interface that exists on that machine.
Shard count 213, with none incomplete A missing shard fails the load long after the cluster has committed to it.

Treat any node that does not pass as a node to rebuild rather than a node to try anyway. Nothing about a 64-rank launch improves by starting it with seven good machines.

Step 6: Set the Launch Environment

Four values differ per node. Everything else in the launch is identical across the cluster, which is what makes this straightforward to script.

Terminal, each node: 1 of 3, what changes per machineSHELL
# Run on every node. RANK is 0 on the head node, then 1 through 7.
RANK=[THIS NODE RANK]
SELF=[THIS NODE PRIVATE IP]
HEAD=10.0.0.210          # the private address of rank 0
NIC=ens6                 # the interface holding the 10.x address

The head address is the private address of rank 0 and is the same on every node. The rank is the one value that has to be unique across the cluster, so set it from the machine name rather than by hand if you are scripting this.

Step 7: Launch All Eight Ranks

The arguments split into three groups: the topology, the backends the hardware calls for, and the parsers that shape the responses. Building them into one variable keeps the launch readable and keeps every node running the same string.

Terminal, each node: 2 of 3, the server argumentsSHELL
# The topology and the two backend choices that follow from the hardware
Q38_ARGS="--trust-remote-code \
  --model-path Qwen/Qwen3.8-2.4T-A95B-FP8 \
  --tp-size 16 --pp-size 4 --nnodes 8 \
  --node-rank $RANK \
  --dist-init-addr $HEAD:20000 --dist-timeout 1800 \
  --linear-attn-prefill-backend flashinfer \
  --linear-attn-decode-backend flashinfer \
  --mamba-full-memory-ratio 0.95 \
  --mamba-ssm-dtype bfloat16 \
  --mamba-radix-cache-strategy extra_buffer \
  --max-prefill-tokens 8192 --page-size 64 \
  --reasoning-parser qwen3 --tool-call-parser qwen3_coder \
  --host 0.0.0.0 --port 30000"

The flags that are specific to Qwen3.8 Max

Everything else in that command is topology. These are the ones that exist because of what this model is.

Flag Why it is set
--linear-attn-prefill-backend flashinfer
--linear-attn-decode-backend flashinfer
The Gated DeltaNet layers default to a Triton path on this GPU generation. NVIDIA H100 and NVIDIA H200 share compute capability 9.0, so a kernel choice verified on one carries to the other, and pinning both halves to FlashInfer is what this deployment ran.
--mamba-full-memory-ratio 0.95 Sets how much of the free memory becomes recurrent state pool. This is the value behind every figure here, and a starting point rather than a figure tuned for this hardware.
--mamba-ssm-dtype bfloat16 Halves the size of each state slot against float32, which directly raises how many requests can run at once.
--mamba-radix-cache-strategy extra_buffer Required to keep --page-size 64. Without it the recurrent cache requires a page size of 1, at a cost in paging efficiency.
--reasoning-parser qwen3 Returns the chain of thought in its own response field instead of inline inside the answer.
--tool-call-parser qwen3_coder Turns the native tool syntax of this model into OpenAI-shaped tool_calls.

Flags are verified against a specific build. Setting the recurrent cache strategy explicitly rather than leaning on a default is what keeps a launch working across an image update.

The container itself needs host networking so the ranks can reach each other on the private addresses, the checkpoint mounted from the ephemeral disk, and enough shared memory for the loader. Run this on all eight nodes.

Terminal, each node: 3 of 3, the launchSHELL
docker rm -f q38 2>/dev/null || true

docker run -d --name q38 --gpus all --network host --ipc=host \
  --shm-size 32g --ulimit memlock=-1 --ulimit stack=67108864 \
  -v /ephemeral/hf:/root/.cache/huggingface \
  --env "HF_TOKEN=$(cat $HOME/hf_token.txt)" \
  --env SGLANG_HOST_IP=$SELF \
  --env NCCL_SOCKET_IFNAME=$NIC \
  --env GLOO_SOCKET_IFNAME=$NIC \
  --env NCCL_IB_DISABLE=1 \
  --env PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
  lmsysorg/sglang:qwen38 \
  sglang serve $Q38_ARGS
💡

Launch the head node first, then the rest together. Rank 0 owns the rendezvous address, so starting it a moment ahead of the others means every other rank finds it on the first attempt. The --dist-timeout 1800 setting gives the group thirty minutes to form, which is generous for 64 ranks and worth keeping while you are getting familiar with the Docker workflow on these machines.

Step 8: Watch the Start-up

The engine reports its memory at five points, and those five numbers are the clearest signal that a launch is going well. Follow the head node.

Terminal, head node: following the interesting linesSHELL
# On the head node. The interesting lines are the memory checkpoints.
docker logs -f q38 2>&1 | grep -E "Init torch|Load weight|Cache is allocated|\
Memory pool|CUDA graph (begin|end)|max_total_num_tokens|Uvicorn"

The ranks meet first. Twenty-six seconds for 64 processes to join one group is normal, and the two lines about collectives are the engine adapting to a group that spans machines rather than anything going wrong.

Start-up: 1 of 3, the weights landOUTPUT
[18:15:44 PP0 TP0] Load weight begin. avail mem=78.05 GB
Multi-thread loading shards: 100% | 213/213 [00:41<00:00, 16.52it/s]
[18:16:29 PP0 TP0] Load weight end. elapsed=45.02 s, type=Qwen3_5MoeForCausalLM,
                   quant=fp8, avail mem=41.73 GB, mem usage=36.32 GB.

Free memory falling from 78.05 GB to 41.73 GB is the moment the deployment becomes real. That 36.32 GB per GPU is the FP8 checkpoint spread across 64 cards. Our first load took 5 minutes 38 seconds reading cold from disk. On a relaunch with the page cache still warm the first rank finished in 45 seconds and the last in 55, and it is the slowest rank that decides when the cluster moves on.

Start-up: 2 of 3, the pools are builtOUTPUT
[18:16:45 PP0 TP0] max_running_requests is capped to 541 by the mamba state
                   cache (max_mamba_cache_size=2164, 4 state slots per request).
[18:16:46 PP0 TP0] Mamba Cache is allocated. max_mamba_cache_size: 2164,
                   conv_state size: 0.28GB, ssm_state size: 9.51GB
[18:16:46 PP0 TP0] KV Cache is allocated. dtype: torch.bfloat16,
                   #tokens: 1803072, K size: 4.30 GB, V size: 4.30 GB
[18:16:46 PP0 TP0] Memory pool end. avail mem=22.78 GB

This is where the hybrid attention stack shows up in the accounting. The recurrent state pool takes 9.79 GB and the key-value cache takes 8.60 GB, and it is the recurrent pool that sets the ceiling on concurrent requests. The engine states the limit and the arithmetic behind it in one line: 2,164 state slots, four slots per request, so 541 requests can run at once.

Start-up: 3 of 3, the graphs and the capacityOUTPUT
[18:19:48 PP0 TP0] Capture target prefill CUDA graph end. elapsed=182.25 s,
                   mem usage=3.87 GB, avail mem=18.76 GB.
[18:20:27 PP0 TP0] Capture target decode CUDA graph end. elapsed=38.97 s,
                   mem usage=1.42 GB, avail mem=17.34 GB.
[18:20:27 PP0 TP0] max_total_num_tokens=1803072, chunked_prefill_size=8192,
                   max_prefill_tokens=8192, max_running_requests=541,
                   context_len=262144, available_gpu_mem=17.34 GB
[18:20:28] INFO:     Uvicorn running on http://0.0.0.0:30000

Where the 78.05 GB on each GPU goes

Measured from the free-memory checkpoints the engine prints, so the segments are differences between real readings rather than an estimate.

Chart: the 78.05 GB on each GPU split into 36.32 GB of weights, 18.95 GB of memory pools, 5.44 GB of CUDA graphs and 17.34 GB free.

Weights 36.32 GB, memory pools 18.95 GB, prefill graphs 4.02 GB, decode graphs 1.42 GB, leaving 17.34 GB free at steady state.

Capturing CUDA graphs is the longest phase of a warm start. The prefill capture walks 58 different token counts and the decode capture walks 52 batch sizes, and together they account for more than half the time between launching the container and answering a request.

The cold start, phase by phase

Total time from container start to a live endpoint was five minutes and 42 seconds on a warm page cache.

Chart: start-up phases in seconds, rendezvous 26.6, weight load 55.0, memory pools 1.6, prefill graphs 182.6 and decode graphs 39.0.

Weight load shown at the warm figure of 55.0 seconds. Reading the shards cold from the ephemeral disk the first time took 5 minutes 38 seconds, which puts a fully cold start at roughly ten minutes.

The same start-up as a timeline

Eight moments worth recognising in the log, with the three that confirm progress marked.

00:00Container starts
The image is already on disk, so this is process start rather than a pull.
00:28Rendezvous begins
26.6 seconds for all 64 ranks to join the process group.
00:58Weight load begins
213 shards read from the ephemeral disk on all eight nodes at once.
01:53Weights resident
36.32 GB per GPU. Free memory falls from 78.05 GB to 41.73 GB.
02:00Memory pools built
Recurrent state and KV cache together take 18.95 GB, leaving 22.78 GB.
05:02Prefill graphs captured
182.6 seconds across 58 token counts. This is the long pole.
05:41Decode graphs captured
39.0 seconds across 52 batch sizes.
05:42Endpoint answers
max_total_num_tokens 1,803,072, context 262,144.

Timestamps from the head node log of this deployment.

Start-up: the engine summaryOUTPUT
Engine startup timings (s): load_weight=54.98, kv_cache_allocation=1.63,
  scheduler_e2e=317.47,
  cuda_graph={prefill=182.60, decode=38.97, target_verify=0.00},
  tokenizer_e2e=328.19

Step 9: Send the First Request

The endpoint speaks the OpenAI chat completions API on port 30000 of the head node. No other rank needs to be reachable from outside the environment.

Terminal, your workstation: the first callSHELL
# From your workstation, against the head node
curl -s http://[HEAD NODE IP]:30000/v1/models | head -c 200

curl -s http://[HEAD NODE IP]:30000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "Qwen/Qwen3.8-2.4T-A95B-FP8",
    "messages": [{"role": "user", "content": "Reply with exactly: OK"}],
    "max_tokens": 512
  }'

While that runs, the cluster looks like this. Roughly 63,400 MiB in use of the 81,559 MiB on each card, at low temperature, because a mixture-of-experts model at low concurrency waits on memory traffic rather than saturating the compute.

nvidia-smi during serving, one node, abridgedOUTPUT
index, name, memory.used [MiB], memory.total [MiB], temperature.gpu, power.draw [W]
0, NVIDIA H100 PCIe, 63552 MiB, 81559 MiB, 26, 76.01 W
1, NVIDIA H100 PCIe, 63396 MiB, 81559 MiB, 24, 78.40 W
2, NVIDIA H100 PCIe, 63396 MiB, 81559 MiB, 36, 84.20 W
...
7, NVIDIA H100 PCIe, 63490 MiB, 81559 MiB, 25, 78.86 W

Where a request goes

One endpoint, four pipeline stages, and a tensor-parallel group of 16 inside each stage.

Your clientport 30000stage 023 layershead node, rank 0stage 123 layersstage 223 layersstage 323 layerstokens stream back from the last stage

Only rank 0 needs the floating IP. The remaining seven nodes work entirely on the private network.

Qwen3.8 Max Inference: Reading What Comes Back

Qwen3.8 Max reasons before it answers, on every request. That single fact changes how a client has to be written, and it is the part of a Qwen3.8 Max implementation most likely to catch a team that has ported code from a model without chain of thought.

The Chain of Thought Arrives in Its Own Field

Because the server was launched with --reasoning-parser qwen3, the reasoning does not appear inline in the answer wrapped in tags. It comes back in message.reasoning_content, and the answer in message.content is clean. A client can log the reasoning, show it, or discard it, without parsing anything.

qwen38_client.py: 1 of 3, one request, standard library onlyPYTHON
import json
import urllib.request

BASE = "http://[HEAD NODE IP]:30000/v1"
MODEL = "Qwen/Qwen3.8-2.4T-A95B-FP8"


def ask(prompt, max_tokens=4096):
    body = {
        "model": MODEL,
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0.7,
        "top_p": 0.8,
        "max_tokens": max_tokens,
    }
    request = urllib.request.Request(
        BASE + "/chat/completions",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=1800) as response:
        return json.load(response)
qwen38_client.py: 2 of 3, reading the responsePYTHON
def show(prompt, max_tokens=4096):
    payload = ask(prompt, max_tokens)
    choice = payload["choices"][0]
    message = choice["message"]
    usage = payload["usage"]

    # The qwen3 reasoning parser puts the chain of thought in its own field,
    # so the answer arrives clean and the reasoning can be logged or dropped.
    thinking = message.get("reasoning_content") or ""
    answer = message.get("content") or ""

    print("finish_reason  :", choice["finish_reason"])
    print("reasoning      :", usage["reasoning_tokens"], "tokens")
    print("completion     :", usage["completion_tokens"], "tokens")
    print("answer         :", len(answer), "characters")
    return answer

The usage block carries a reasoning_tokens field alongside completion_tokens, which is the number to watch. Reasoning is not billed separately in a self-hosted deployment, but it does consume the same budget as the answer.

Reasoning and the Answer Share One Token Budget

This is the behaviour to design around. max_tokens covers the reasoning and the answer together, not the answer alone. We sent the same code prompt at two budgets.

One budget, two outcomes

The same prompt, the same model, the same sampling settings. Only max_tokens changed.

max_tokens 2,048Answer truncated in the middle of a function
1,871 reasoning tokens
177
max_tokens 6,144No answer tokens at all
6,144 reasoning tokens
Both responses came back with finish_reason set to length. The second one carried an empty content field and 6,144 reasoning tokens.

Captured from the two code prompts in this deployment. At 2,048 tokens the answer stopped in the middle of a function body; at 6,144 the content field came back empty.

usage, with max_tokens set to 2,048JSON
{
  "prompt_tokens": 112,
  "completion_tokens": 2048,
  "reasoning_tokens": 1871,
  "total_tokens": 2160
}
usage, with max_tokens set to 6,144JSON
{
  "prompt_tokens": 111,
  "completion_tokens": 6144,
  "reasoning_tokens": 6144,
  "total_tokens": 6255
}

At 6,144 the model spent the entire allowance thinking and emitted no answer tokens at all. A budget sized for a model without chain of thought does more than clip the tail here. It can return an empty response, or code that looks complete and is not.

qwen38_client.py: 3 of 3, the check that belongs in every clientPYTHON
# A budget sized for a model without chain of thought can return nothing at
# all here, because reasoning and the answer share one allowance. Treat a
# length stop as a retry signal rather than as a finished response.
answer = show("Write a Python function chunked(iterable, n) that yields "
              "lists of length n, lazily, raising ValueError if n < 1.",
              max_tokens=4096)

if not answer.strip():
    print("empty answer: raise max_tokens or lower reasoning_effort")
⚠️

Check finish_reason on every response. A value of length means the budget ran out, and on this model that can mean an answer that stops mid-token or no answer at all. Treat it as a retry signal with a larger budget or a lower reasoning effort, never as a completed response.

Reasoning Effort Is the Control Worth Wiring Up

The Qwen3.8-2.4T-A95B model card documents three reasoning levels, low, medium and xhigh, with xhigh as the default. Every measurement below was taken at the default, which is the most expensive setting and the right one to measure against. It is also the first lever to reach for when a workload does not need deep deliberation on every turn.

Request body, asking for a shorter deliberationJSON
# reasoning_effort is the documented control. The default is xhigh, which is
# what every measurement above was taken at.
{
  "model": "Qwen/Qwen3.8-2.4T-A95B-FP8",
  "messages": [{"role": "user", "content": "..."}],
  "reasoning_effort": "low",
  "max_tokens": 4096
}

How much of each response was reasoning

Five prompts of rising difficulty. Each bar is one response at 100 per cent, split into thinking and answering, with its total completion tokens above it.

Chart: the share of each response spent reasoning, rising from 62 per cent on a factual prompt to 100 per cent on the largest code budget.

Taken from the usage block of each captured response. The rightmost bar is the 6,144 token budget that returned no answer at all.

The shape of that chart is the useful part. On a two-sentence factual question the model spent 105 tokens thinking and 65 answering. On a code task it spent 1,871 thinking and 177 answering. Reasoning grows with difficulty far faster than the answer does, so a fixed budget that suits simple traffic will fail on hard traffic rather than degrade on it.

Tool Calling Works End to End

The qwen3_coder parser turns the native tool syntax of this model into OpenAI-shaped tool_calls. We tested it with a loop that executes the tools and feeds each result back, because a broken parser still produces plausible-looking first-turn output.

Tool definition, one of fivePYTHON
TOOLS = [{
    "type": "function",
    "function": {
        "name": "plan_cluster",
        "description": "Smallest cluster that leaves more than headroom_gb "
                       "free per GPU and admits a legal (tp, pp) split.",
        "parameters": {
            "type": "object",
            "properties": {
                "weights_tb": {"type": "number"},
                "vram_gb": {"type": "number"},
                "gpus_per_node": {"type": "integer"},
                "layers": {"type": "integer"},
                "headroom_gb": {"type": "number"},
            },
            "required": ["weights_tb", "vram_gb", "gpus_per_node", "layers"],
        },
    },
}]
The loop, with results fed back as role toolPYTHON
messages = [
    {"role": "system", "content": "You are a deployment engineer. Call a tool "
                                  "for every factual number."},
    {"role": "user", "content": TASK},
]

for turn in range(8):
    payload = chat(messages, TOOLS)
    message = payload["choices"][0]["message"]
    calls = message.get("tool_calls") or []

    # Echo the assistant turn back so the model sees its own calls
    messages.append({k: v for k, v in message.items()
                     if k != "reasoning_content"})
    if not calls:
        print(message["content"])
        break

    for call in calls:
        name = call["function"]["name"]
        args = json.loads(call["function"]["arguments"] or "{}")
        result = IMPLEMENTATIONS[name](**args)
        messages.append({"role": "tool", "tool_call_id": call["id"],
                         "name": name, "content": json.dumps(result)})

The task was to size a cluster for this exact model using only tool results. It made four dependent calls in the right order, taking each argument from the previous result, across eight turns and 54.6 seconds.

The captured tool trace, four dependent callsOUTPUT
lookup_model {"name": "qwen3.8-fp8"}
  -> {"weights_tb": 2.5, "layers": 92, "total_params_b": 2400}

lookup_gpu   {"flavor": "h100-80g"}
  -> {"vram_gb": 80, "gpus_per_node": 8, "usd_per_hr_node": 16.0}

plan_cluster {"weights_tb": 2.5, "vram_gb": 80, "gpus_per_node": 8,
              "layers": 92, "headroom_gb": 25}
  -> {"nodes": 8, "gpus": 64, "weights_gb_per_gpu": 39.1,
      "free_gb_per_gpu": 40.9,
      "legal_splits": [{"tp": 64, "pp": 1}, {"tp": 32, "pp": 2},
                       {"tp": 16, "pp": 4}],
      "rejected_smaller_clusters": [
        {"nodes": 6, "free_gb_per_gpu": 27.9,
         "why": "fits in memory but no tp dividing 64 heads"},
        {"nodes": 7, "free_gb_per_gpu": 35.4,
         "why": "fits in memory but no tp dividing 64 heads"}]}

cost         {"nodes": 8, "usd_per_hr_node": 16.0, "hours": 3}
  -> {"usd_per_hr_cluster": 128.0, "usd_total": 384.0}

The final answer then reproduced the non-obvious part without being prompted for it: six and seven node clusters fit the weights in memory and admit no legal placement, so they are infeasible. That is the same conclusion the architecture section reached, arrived at independently from the tool output.

It Gets the Arithmetic Right

Two of the prompts had ground truth we could check rather than eyeball. Asked for the annual cost of a 6.5 kW rack at $0.11 per kWh with cooling billed at 40 per cent of the power bill, it returned $6,263.40 for power, $2,505.36 for cooling and $8,768.76 in total, which matches an independent calculation exactly. Asked how 92 layers can be split evenly across pipeline stages, it returned the full set.

Response, the pipeline question, abridgedOUTPUT
The possible numbers of pipeline stages are 2, 4, 23, 46 and 92.

Let s be the number of stages. For exactly equal layers per stage, 92 / s
must be an integer, so s must divide 92. The prime factorisation is
92 = 2^2 x 23, so its positive divisors are 1, 2, 4, 23, 46 and 92.
Excluding a single stage leaves 2, 4, 23, 46, 92.

Throughput, and What Sets It

We swept concurrency from one request to 32 against the live endpoint, with a 256 token cap on each, and measured what came back.

Terminal, your workstation: the concurrency sweepSHELL
python3 q38_bench.py \
  --base http://[HEAD NODE IP]:30000/v1 \
  --model Qwen/Qwen3.8-2.4T-A95B-FP8 \
  --levels 1,4,16,32 --max-tokens 256 --out ./bench
The sweep, as measuredOUTPUT
 conc   ok   wall_s  agg tok/s  per-req t/s     p50     p95
    1    1    22.42       10.9         10.9   22.42   22.42
    4    4    32.57       30.8          7.8   32.31   32.56
   16   16    87.45       45.5          2.9   86.82   87.44
   32   32    95.83       81.6          2.7   95.28   95.83

== peak aggregate 81.6 tok/s at concurrency 32

Aggregate throughput against median latency

Aggregate output rises 7.5 times from one request to 32. Per-stream speed falls, which is the expected trade.

Chart: aggregate throughput of 10.9, 30.8, 45.5 and 81.6 tokens per second at concurrency 1, 4, 16 and 32, against median latency.

Every request in the sweep succeeded: 53 in total across the four levels, with no failures at any concurrency.

CONCURRENCY 1
10.9
aggregate tokens per second
10.9 per stream
22.4 second median latency
CONCURRENCY 4
30.8
aggregate tokens per second
7.8 per stream
32.3 second median latency
CONCURRENCY 16
45.5
aggregate tokens per second
2.9 per stream
86.8 second median latency
CONCURRENCY 32
81.6
aggregate tokens per second
2.7 per stream
95.3 second median latency
 

The Recurrent State Pool Is the Ceiling, Not the Context Length

On a conventional transformer, concurrency runs out when the key-value cache fills, so long prompts and many users compete for the same resource. Here the engine reported a limit of 541 concurrent requests set by the recurrent state pool, from 2,164 state slots at four slots per request. That limit does not move with prompt length, because a Gated DeltaNet layer carries the same fixed state whether the sequence is a thousand tokens or a hundred thousand.

Two flags move it. Raising --mamba-full-memory-ratio gives the pool more of the free memory, and --mamba-ssm-dtype bfloat16 halves the size of every slot against float32. Both are already set in the launch above.

Two Limits on These Figures

These numbers are a floor rather than a ceiling, for two reasons.

What is left on the table

Both are headroom that a production deployment can claim.

What Effect What to do about it
The mixture-of-experts kernels are running an untuned configuration The engine ships tuned configurations per model, GPU and tensor degree, and there is none published for 512 experts at tensor parallel 16 on NVIDIA H100. It says so during start-up. Tune the kernel configuration for this shape, or move to a placement that has a published configuration.
No speculative decoding The checkpoint contains a multi-token prediction head, and a pipeline degree above one disables it. Single-token decoding is the slow path for a sparse model. Fewer, larger GPUs allow pipeline degree 1, which brings the head back into play.

The start-up log names the missing kernel configuration explicitly, which is a useful thing to grep for on any new placement.

Start-up, the kernel configuration noticeOUTPUT
Using default MoE kernel config. Performance might be sub-optimal!
Config file not found at .../configs/triton_3_7_1/
  E=512,N=128,device_name=NVIDIA_H100_PCIe.json
📘

The route to more speed is fewer, larger cards. Every constraint here traces back to fitting 2.50 TB onto 80 GB cards. NVIDIA H200 SXM at 141 GB per card halves the GPU count, which narrows the tensor group and keeps far more traffic inside a machine. NVIDIA Blackwell with a 4-bit checkpoint brings the whole model into a single node, which removes the pipeline dimension and the constraint that comes with it.

Qwen3.8 Max Deployment Cost

A Qwen3.8 Max deployment cost is easy to reason about once the node count is settled, because the cluster bills as eight identical machines by the minute. The rate is the same whether the GPUs are loading weights, capturing graphs or serving traffic, so the only open question is how long each phase lasts.

What it costs to hold 2.50 TB of weights for an hour

This deployment is the first row. The others carry the same checkpoint on different hardware.

Configuration GPUs Per GPU hour Per hour
NVIDIA H100 80GB PCIe on Spot, used here 64 $2.00 $128.00
NVIDIA H100 SXM, on demand 64 $3.20 $204.80
NVIDIA H100 SXM, reserved from 64 $2.72 $174.08
NVIDIA H200 SXM, on demand 32 $3.99 $127.68

Per GPU rates from the Hyperstack GPU pricing page, multiplied by the GPU count each configuration needs. The NVIDIA H200 SXM row is 32 GPUs rather than 64, because 141 GB cards hold 2.50 TB in half as many, and that puts an hour of it within 32 cents of the Spot cluster used here.

What Each Phase Cost in This Deployment

These are the phases we timed, priced at the Spot cluster rate of $128.00 per hour, which is $2.13 a minute. Everything here is measured rather than modelled.

Measured phases and their cost

A deployment spends most of its money before it answers a single request, and then the bill settles into a flat hourly rate.

Phase Duration Cost at $2.13 per minute How often you pay it
Engine start, warm page cache 5 min 42 s $12.14 Every launch after the first
Engine start, cold from disk about 10 min about $21.30 The first launch of a session
Chat and reasoning prompts, four of them 5 min 50 s $12.43 Workload dependent
Tool-calling scenarios, two of them 3 min 9 s $6.70 Workload dependent
Concurrency sweep, 1 to 32 3 min 58 s $8.46 Workload dependent
Steady-state serving per hour $128.00 Continuously, while running

Durations from the captured latencies of each run. The 2.50 TB download runs before any of this and is the longest single phase in the deployment, so start it as soon as the machines are active and do the firewall work while it runs.

The Three Levers That Move the Bill

Only one of these is a tuning exercise. The other two are decisions taken before the cluster exists.

What to change, in order of effect

Node count sets the floor, the pricing model scales it, and start-up time is what you pay repeatedly while iterating.

Lever Effect
Card size The largest lever, and the table above prices it: half the GPUs for the same money, with 62.9 GB free per GPU rather than 40.9 GB. A 4-bit NVIDIA Blackwell checkpoint at 1.32 TiB goes further again and fits one node.
Pricing model Spot is the cheapest route onto NVIDIA H100 80GB PCIe at $2.00 per GPU hour. On the SXM flavours the lever is a reservation instead, which starts at $2.72 per GPU hour against $3.20 on demand.
Start-up time Capturing CUDA graphs is 221.6 seconds of every launch. That cost is fixed per launch rather than per request, so it matters while you are iterating and disappears once the endpoint is long-lived.

Billing is per minute of runtime rather than by the hour, so a short experiment is charged as a short experiment.

📘

Why self-host a model that is also available as an API. The open checkpoint gives you the full 262,144 token context with no per-token metering, the sampling and reasoning controls in your own hands, weights that stay inside your own environment, and an activated parameter count you can read. Those are the reasons a Qwen3.8 Max deployment earns its cluster, rather than a per-token comparison.

Why Deploy Qwen3.8 Max on Hyperstack?

Hyperstack is a cloud platform built for AI and machine learning workloads. Here is what a 64-GPU deployment needs from a provider, and how that maps onto the platform:

64 NVIDIA H100 GPUs on One Private Network
Eight nodes of NVIDIA H100 80GB PCIe carry 39.1 GB per GPU. One environment gives them a private network, and all 64 ranks met in 26.6 seconds.
NVMe Storage Sized for a 2.50 TB Checkpoint
A 96 GB root disk cannot hold 213 shards. The ephemeral NVMe disk at /ephemeral took the whole 2.50 TB on every node.
Matched Driver, CUDA and Container Runtime
The Ubuntu 24.04 R570 CUDA 12.8 with Docker image already matches the serving image, so every node goes from ssh to docker run with no driver work.
Spot Pricing and Per-Minute Billing
Spot virtual machines put 64 NVIDIA H100 80GB PCIe GPUs at $128.00 per hour, billed by the minute.
Firewall Rules Scoped to the Cluster
Firewall rules attach per machine, so SSH and the endpoint stay locked to your own address while 10.0.0.0/8 opens only inside the environment.
A Route to Fewer, Larger Cards
NVIDIA H200 SXM at 141 GB halves the GPU count, and NVIDIA Blackwell reservations take a 4-bit checkpoint into one node.

Serve a 2.4 trillion parameter model yourself

Deploy Qwen3.8 Max on 64 NVIDIA H100 GPUs

Eight nodes on Spot at $128.00 per hour. Our deployment loaded 2.50 TB across 64 GPUs, reached a live endpoint in under six minutes of engine start-up and served the full concurrency sweep without a single failed request.

64x NVIDIA H100 80GBTensor 16 x pipeline 4262,144 token context$128.00 per hour

Launch an NVIDIA GPU cluster on Hyperstack today.

FAQs

What hardware do you need to deploy Qwen3.8 Max?

The FP8 checkpoint is 2.50 TB, which is 39.1 GB per GPU across 64 cards. We ran it on eight Hyperstack nodes of 8x NVIDIA H100 80GB PCIe, leaving 17.34 GB free per GPU once the caches and CUDA graphs were resident.

How much does a Qwen3.8 Max deployment cost?

Eight nodes of 8x NVIDIA H100 80GB PCIe on Spot are $2.00 per GPU hour, so $128.00 an hour for the cluster, billed by the minute. A warm engine start costs $12.14 and the full concurrency sweep cost $8.46.

Can you turn thinking off on Qwen3.8 Max?

No. Every completion carries chain of thought, and reasoning shares the same max_tokens budget as the answer. The documented control is reasoning_effort, which takes low, medium or xhigh, and defaults to xhigh.

Why does Qwen3.8 Max need eight nodes and not six?

Six nodes hold the weights with 27.9 GB free per GPU, then fail on arithmetic. Tensor parallel 24 and 12 divide neither the 64 query heads nor the layer count, so no legal placement exists. Seven nodes fail the same test.

How long does Qwen3.8 Max take to start serving?

Five minutes 42 seconds from container start to a live endpoint with a warm page cache, or roughly ten minutes reading the 213 shards cold. Capturing CUDA graphs is 221.6 seconds of that and happens on every launch.

Fareed Khan

Fareed Khan

calendar 13 Aug 2026

Read More
tutorials Tutorials link

Deploy MiniMax H3 on GPU Cloud for Video and Audio in One Pass

MiniMax H3 is a 33 billion parameter omni-modal generative ...

MiniMax H3 is a 33 billion parameter omni-modal generative system, open sourced on 3 August 2026, and it does something the text-to-video models before it did not: it writes the soundtrack at the same time as the picture. Video latents and audio latents are predicted by the same transformer, in the same forward pass, from one packed multimodal sequence. What lands on disk is a single MP4 carrying H.264 video at 24 frames per second and AAC stereo at 32 kHz, already in sync, with no separate audio model and no post-production step.

This guide serves it on one Hyperstack node: four NVIDIA H100 80GB PCIe GPUs on Spot at $8.00 per hour, through SGLang Diffusion. Every number below comes from a single session on 11 August 2026 that pulled 196 GB of checkpoints, reached a live endpoint, and produced eleven finished clips across all three task modes. Total generation time on the four GPUs was 51.5 minutes for 65.3 seconds of video, and every clip was verified with ffprobe to be carrying two channels of 32 kHz audio.

Serving it yourself means three decisions: how to place the model across four GPUs, how to configure the collectives for the fabric those GPUs sit on, and how to write a prompt without the hosted preprocessor that normally rewrites one. If you have read our technical deep dive into Kimi K3, this is the same shape of exercise on a much smaller cluster and a generative rather than a language workload. If you want a lighter video model on a single GPU first, our Wan 2.1 video generation tutorial covers that ground.

What MiniMax H3 Is, and Which Part of It You Can Run Yourself

H3 is not one model. The model card describes three modules, and only the middle one has open weights. Getting that distinction right before you book hardware saves a great deal of confusion later, because most of the published sample output was produced by all three working together.

The three modules of MiniMax H3

H3-Context-IR turns free-form input into structure, H3-Base generates, H3-Regenerate-2K redraws at higher resolution. Only the middle box has published weights.

THE COMPLETE H3 SYSTEM, AS MINIMAX SHIPS ITSTAGE 1H3-Context-IRTurns free-form text, images,audio and video into structureHosted APISTAGE 2H3-BaseGenerates 768p video and nativestereo audio from that structureOpen weightsSTAGE 3H3-Regenerate-2KFeeds the 768p result back incontext to redraw it at 2KHosted APIWHAT THIS GUIDE DEPLOYS, AND WHAT IT REPLACESStructured promptwritten by hand to theofficial specificationH3-Base, self-hosted4x NVIDIA H100 80GBon one Hyperstack node768p MP4H.264 24 fps withAAC stereo at 32 kHz

Redrawn from the system overview on the MiniMaxAI/MiniMax-H3 model card.

That leaves a self-hosted deployment with two consequences and one advantage. The consequences: output is capped at a 768 pixel short edge, and nothing rewrites a rough prompt into something the model understands well. The advantage: the weights are the whole system you are running, so results are reproducible from a seed and a prompt, with no hidden service in the middle changing what you asked for.

Inside H3-Base

A 33 billion parameter dense single-stream transformer, fed by a Qwen3-VL text and vision encoder and two separate variational autoencoders, predicting picture and sound together.

33B dense Omni-TransformerQwen3-VL-32B encoderBF16CFG-distilled24 fps32 kHz stereo4 to 15 secondsMM-RoPE over t, h, w
TRANSFORMER
33B parameters
single stream, roughly 13B of it in AdaLN branches that are cacheable at inference
TEXT AND VISION
Qwen3-VL-32B
full pretrained weights, hidden states taken from the 50th layer
VISUAL VAE
f16t4d24
16x spatial and 4x temporal compression, 24 latent channels, patchified 1x2x2
AUDIO VAE
32 kHz to 40 Hz
one encoder shared by both channels, run independently, recombined into stereo
ATTENTION
Full, in this release
sparse attention was trained but is not part of the initial open source drop
CHECKPOINTS
Two partitions
FL2VA serves t2va and fl2va, Ref2VA serves ref2va and needs its own server

Architecture figures from the MiniMaxAI/MiniMax-H3 model card and the MiniMax H3 research write-up. Read them alongside the licence: the weights carry the MiniMax H3 Community License Agreement rather than a stock open source licence, and MiniMax publishes a separate application form covering the USA, EU, UK and South Korea.

The two output streams stay together because of how the sequence is built. Text goes through the H3-Encoder. Images and video go through the H3-Encoder and the visual VAE. Audio goes only through the audio VAE. All of it is then packed into one sequence with three-dimensional rotary position embeddings over time, height and width, and the transformer predicts video latents and audio latents jointly. Synchronisation is not a post-process, it is a property of the sequence.

One forward pass, two decoders

Nothing in the attention or feed-forward layers is modality specific. Only the input and output layers and the AdaLN branches know which modality they are looking at.

INSIDE H3-BASE: ONE FORWARD PASS, BOTH MODALITIES OUTText promptImages and videoAudio referenceH3-EncoderQwen3-VL-32B, hidden statesfrom its 50th layerH3-VisualVAE16x spatial, 4x temporal, 24 ch,patchified 1x2x2H3-AudioVAE32 kHz per channel down to40 Hz latent tokensencoded twiceOne packed multimodal sequencethree-dimensional MM-RoPE over time, height and widthH3-Omni-Transformer33B dense single-stream, modality-specific AdaLN, full attention in this releaseVideo latentsAudio latentsH.264 at 24 fps1344 x 768, 158 framesAAC stereo at 32 kHztwo channels, one pass

Drawn from the architecture section of the MiniMaxAI/MiniMax-H3 model card. The frame count shown is from our own 6 second 16:9 output.

What is open, and what is not

Component In the open release What it means for a deployment
H3-Base FL2VA Yes, BF16 weights Text-to-video-audio and first or last frame conditioning, from one server
H3-Base Ref2VA Yes, BF16 weights Reference conditioning on images, video and audio, from a second server
H3-Context-IR No, hosted API You write the structured prompt yourself, to the published specification
H3-Regenerate-2K No, hosted API Self-hosted output stays at a 768 pixel short edge
Sparse attention Not yet Inference runs full attention, so cost grows quickly with sequence length
📘

Read the licence before you build on this. H3 ships under the MiniMax H3 Community License Agreement, not a stock open source licence, and MiniMax publishes a separate application form covering the USA, the EU, the UK and South Korea. The open source announcement and the model card are the two documents to read first.

The Arithmetic That Decides the Hardware

Two numbers set the shape of the deployment. The first is what the checkpoints weigh. The repository hosts the original checkpoint and the diffusers format side by side, so scoping the download matters: pulling only model_index.json, FL2VA/* and Ref2VA/* landed 196 GB across 163 files on our node.

The second is what has to be resident while a generation runs. The official vLLM recipe puts the BF16 footprint of one task partition at roughly 135 GiB before any activations: two DiTs at 66.3 GB each, the Qwen3-VL-32B encoder at 51.5 GB, the video VAE at about 10 GB and the audio VAE at about 0.6 GB. No single 80 GB card holds that, which is why four GPUs is the floor rather than a preference.

How those four GPUs are arranged is a choice rather than a default. The SGLang MiniMax-H3 cookbook publishes three placements for 4x NVIDIA H100 80GB, all of which keep the whole pipeline resident, and they trade latency against memory.

Three ways to place H3 on four NVIDIA H100 80GB GPUs

All three keep the pipeline resident. They differ by less than a second of pipeline latency and by 16 GB of peak memory per GPU, which is the entire trade-off.

 

Latency and peak memory from the SGLang MiniMax-H3 cookbook, hardware profile h100. The highlighted bar is the placement used throughout.

The cookbook labels tensor parallel 2 with Ulysses degree 2 the fastest measured placement on this hardware, and it leads by 0.61 seconds. It also states plainly that pure Ulysses degree 4 cannot keep the pipeline resident on 80 GB NVIDIA H100 cards. We ran tensor parallel 4 with Ulysses degree 1, the lowest-memory row, because it leaves the most headroom for a long reference clip and asks the least of the interconnect on a PCIe fabric.

💡

Sequence parallelism and tensor parallelism are not interchangeable here. Ulysses degree splits the sequence across GPUs and exchanges attention heads between them. Tensor parallel splits the weights. The product of the two has to equal the GPU count, so on four GPUs your only choices are 4x1, 2x2 and 1x4, and each one moves a different amount of data at a different point in the pipeline.

Matching the Configuration to Your Node

Two settings depend on how the four GPUs in your node are wired together. Each one is a single line in the launch command, and each one follows from a check that takes a second.

1. Read the fabric, then set NCCL to match it

Ask the driver how the four cards are connected, and ask CUDA what it will pass directly between them. The two answers agree, and together they tell you which transport NCCL should use.

Terminal, on the nodeSHELL
# What the driver reports about how the four cards are wired together
nvidia-smi topo -m

# And what CUDA will actually permit between them
python3 -c "
import torch
for i in range(4):
    for j in range(4):
        if i != j:
            print(i, j, torch.cuda.can_device_access_peer(i, j))
"
Output of nvidia-smi topo -m and can_device_access_peerOUTPUT
        GPU0    GPU1    GPU2    GPU3    CPU Affinity    NUMA Affinity
GPU0     X      PHB     PHB     PHB     0-123           0-1
GPU1    PHB      X      PHB     PHB     0-123           0-1
GPU2    PHB     PHB      X      PHB     0-123           0-1
GPU3    PHB     PHB     PHB      X      0-123           0-1

0 1 False    0 2 False    0 3 False
1 0 False    1 2 False    1 3 False
2 0 False    2 1 False    2 3 False
3 0 False    3 1 False    3 2 False

PHB means every pair of GPUs communicates through a PCIe host bridge, which is the fabric on this flavour, and torch.cuda.can_device_access_peer reports False for all twelve ordered pairs. Collectives therefore travel through host memory, and NCCL is told so explicitly with two NCCL environment variables: NCCL_P2P_DISABLE=1 selects that path, and NCCL_CUMEM_ENABLE=0 keeps the allocator on the matching one. Neither costs anything measurable here, because the sequence is split four ways rather than thirty-two and the collectives are small next to the denoising loop they sit inside.

The same four GPUs, two different fabrics

A PCIe flavour carries every collective through the host bridge. An NVLink node carries them card to card. The model is identical either way, and only NCCL needs to know which it is on.

HOW THE FOUR GPUS ARE WIRED, AND WHAT NCCL NEEDS TO KNOWThis flavour: PHB on every pairGPU 0GPU 1GPU 2GPU 3PCIe host bridgecan_device_access_peer = False x12An NVLink node, for contrastGPU 0GPU 1GPU 2GPU 3NV12 links, direct peer-to-peer on every paircan_device_access_peer = TrueOn a PCIe fabricNCCL_P2P_DISABLE=1 routes the collectives through host memory, which is the path this fabric provides.

Topology read directly from nvidia-smi topo -m on the node, and peer access probed with torch.cuda.can_device_access_peer across all twelve ordered pairs.

📘

This is a property of the flavour, not of the GPU. Hyperstack also offers NVIDIA H100 PCIe-NVLink and NVIDIA H100 SXM flavours, which expose peer-to-peer directly. Run nvidia-smi topo -m on whatever you deploy: it takes a second, and it tells you which of the two columns above you are looking at.

2. Choose the parallel placement that suits the fabric

All three documented placements fit on four NVIDIA H100 cards. Tensor parallel 2 with Ulysses degree 2 is the fastest of them by 0.61 seconds, and it is the one to reach for on a node with peer-to-peer. On a PCIe fabric, tensor parallel 4 with Ulysses degree 1 is the better fit: it is the lowest-memory row at 49.80 GB per GPU, and with Ulysses degree 1 there are no sequence-parallel groups to form, so nothing crosses the host bridge that does not have to.

Trading 0.61 seconds of pipeline latency for 16 GB of headroom per card is a comfortable trade when a single Ref2VA job peaks at 54.9 GB as it is, and it leaves room for a longer reference clip than the fastest row would.

Where each placement sits against the 80 GB ceiling

Every configuration below fits. The question is how much room is left for a long reference clip, and how many groups the engine has to form to get there.

 

Cookbook figures for the three documented placements, and our own peak memory as reported by the server on the two Ref2VA jobs.

How to Deploy MiniMax H3 on Hyperstack

Now, let us walk through the step-by-step process of standing up the node and serving MiniMax H3 across all four GPUs.

📘

The whole exercise runs on one virtual machine. There is no cluster to form and no private network to configure, which makes it a considerably simpler deployment than a multi-node language model. For the parallelism theory behind the placement above, see How to Run Distributed Inference with vLLM.

Four things decide the flavour, and n3-H100x4 supplies all four.

What the deployment needs What the flavour gives Why it matters here
Four GPUs of at least 80 GB 4x NVIDIA H100 80GB PCIe One task partition is about 135 GiB in BF16, so it has to be split four ways
Room for 196 GB of weights 3.2 TB ephemeral NVMe at /ephemeral The 96 GB root disk cannot hold the checkpoints and the container image
A matched driver and container runtime Ubuntu 24.04, R570, CUDA 12.8, Docker The deployment goes from ssh straight to docker run
Host memory for the decode path 720 GB RAM, 124 vCPUs Reference video is decoded on the host before it ever reaches a GPU

Step 1: Accessing Hyperstack

First, you will need an account on Hyperstack.

  • Go to the Hyperstack website and log in.
  • If you are new, create an account and set up your billing information. Our documentation can guide you through the initial setup.

Step 2: Deploying a 4x NVIDIA H100 Virtual Machine

From the Hyperstack dashboard, launch a single GPU virtual machine. The GPU count is fixed by the memory arithmetic above: one task partition is about 135 GiB in BF16, and no smaller shape holds it.

  • Initiate Deployment: Click the "Deploy New Virtual Machine" button on the dashboard.

The Deploy New Virtual Machine button on the Hyperstack virtual machines dashboard

The button sits above the virtual machine list, under Cloud then Virtual Machines.

  • Select Hardware Configuration: Choose a 4x NVIDIA H100 80GB PCIe flavour, listed in the flavour reference as n3-H100x4. Our run used the Spot variant, n3-H100x4-spot, at $2.00 per GPU per hour, which is $8.00 per hour for the node. The flavour also carries 124 vCPUs, 720 GB of RAM and a 3.2 TB ephemeral disk, all three of which this deployment uses.

step 2

Choose the NVIDIA H100-80G-PCIe card, then set the count to 4x in its dropdown.

  • Choose the Operating System: Select the "Ubuntu Server 24.04 LTS R570 CUDA 12.8 with Docker" image. The driver line has to match what the SGLang container expects, and this image also ships Docker and the NVIDIA container runtime ready to use, so there is no driver work at all.
  • Environment: Choose an environment carrying NVIDIA H100 stock. Our run used CANADA-1.
  • Select a Keypair: Choose an existing SSH keypair, or import one now. You will need it in step 4.
  • Network Configuration: Assign a Public IP so you can reach the machine over SSH.
  • Enable Ephemeral Storage: Make sure the ephemeral disk is attached. The checkpoints are 196 GB and the root disk is 96 GB, so this is not optional here.
  • Review and Deploy: Check the settings and click "Deploy". The machine reaches ACTIVE in a couple of minutes.

Selecting Ubuntu Server 24.04 LTS R570 CUDA 12.8 with Docker in the Hyperstack OS image picker

Open the Ubuntu dropdown and take the R570 CUDA 12.8 with Docker build.

💡

Spot capacity suits this workload well, with one condition. Spot VMs run on surplus capacity at a lower rate and can be reclaimed when that capacity is needed, so treat the node as disposable and copy each clip off as it finishes. For a generation run like this one it turns a $10.00 per hour node into an $8.00 per hour node.

Step 3: Configure the Firewall

This deployment needs one inbound rule, and one port deliberately left shut. Add the first under firewall rules on the virtual machine:

Port Source Why
22 your public IP /32 SSH access to the node
30010 closed The inference API. Leave it shut and drive it from the node itself

Port 30010 stays closed deliberately. Every generation here runs from a script on the node, because reference images and clips are passed as file:// URIs that only resolve inside the server container. Driving the endpoint remotely would put those references out of reach and expose an unauthenticated API at the same time. If you do need it from your own machine, forward it over the SSH session you already have rather than opening a port:

Terminal, on your own machineSHELL
# Optional. Forwards the endpoint to localhost:30010 on your own machine without
# opening anything on the node. Note that file:// conditioning still resolves only
# inside the container, so image and video references need the on-node client.
ssh -i /path/to/your-key -L 30010:127.0.0.1:30010 ubuntu@[PUBLIC IP]
⚠️

The inference endpoint has no authentication. Anything that reaches port 30010 can spend your GPU hours four minutes at a time. Keep it bound to the node, and never open it to 0.0.0.0/0.

Step 4: Accessing Your Node

Once the virtual machine is running, copy its Public IP from the dashboard and connect.

Terminal, on your own machineSHELL
# The public IP is on the virtual machine's detail page in the dashboard
ssh -i /path/to/your-key ubuntu@[PUBLIC IP]

Confirm the two things this deployment depends on: four NVIDIA H100 cards, and a large disk mounted at /ephemeral.

Terminal, on the nodeSHELL
nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader
df -h / /ephemeral | tail -2
Output of nvidia-smi and dfOUTPUT
0, NVIDIA H100 PCIe, 81559 MiB
1, NVIDIA H100 PCIe, 81559 MiB
2, NVIDIA H100 PCIe, 81559 MiB
3, NVIDIA H100 PCIe, 81559 MiB

/dev/vda1        96G   13G   83G  14% /
/dev/vdb        3.2T   89M  3.0T   1% /ephemeral

The disk line is what shapes the rest of the guide. The machine has two disks and only one of them matters here: the root filesystem is roughly 96 GB, while the NVMe disk is mounted separately at /ephemeral and offers around 3.2 TB on this flavour. It is documented under ephemeral storage. The two checkpoint partitions are 196 GB together and the container image adds more on top, so the model cache has to live on the big disk.

Step 5: Prepare the Disks and Pull the Checkpoints

This script does four things: creates the working directories on /ephemeral, installs ffmpeg and jq, and then starts the container image pull and the 196 GB weight download at the same time rather than one after the other.

setup_node_mmh3.sh, on the nodeSHELL
#!/bin/bash
# Per-node prep for MiniMax-H3 on a single 4x NVIDIA H100 box.
# The Hugging Face cache goes to /ephemeral: root is only 96 GB, the checkpoints are not.
set -u

sudo mkdir -p /ephemeral/hf /ephemeral/media /ephemeral/out
sudo chown -R ubuntu:ubuntu /ephemeral/hf /ephemeral/media /ephemeral/out

sudo apt-get update -qq
sudo apt-get install -y -qq ffmpeg jq
python3 -m pip install -q --break-system-packages huggingface_hub hf_transfer

# Pull the engine image and the weights at the same time: neither one waits for the other.
nohup docker pull lmsysorg/sglang:dev >/tmp/pull.log 2>&1 &

nohup env HF_HOME=/ephemeral/hf HF_HUB_ENABLE_HF_TRANSFER=1 HF_TOKEN="$(cat ~/hf_token.txt)" \
  python3 -c "
from huggingface_hub import snapshot_download
snapshot_download('MiniMaxAI/MiniMax-H3',
                  allow_patterns=['model_index.json', 'FL2VA/*', 'Ref2VA/*'],
                  max_workers=16)
" >/tmp/dl.log 2>&1 &

echo "image pull and checkpoint download started"

The download is the long pole. With hf_transfer enabled and sixteen workers it is bounded by the network rather than the disk. Check it landed before you launch anything.

Terminal, on the nodeSHELL
du -sh /ephemeral/hf/hub/models--MiniMaxAI--MiniMax-H3
find /ephemeral/hf -name "*.safetensors" | wc -l
df -h /ephemeral | tail -1
Output, once the download finishedOUTPUT
196G    /ephemeral/hf/hub/models--MiniMaxAI--MiniMax-H3
163
/dev/vdb        3.2T  197G  2.8T   7% /ephemeral
💡

Scope the download. The repository carries the original checkpoint and the diffusers format alongside each other. SGLang and vLLM both want the original layout, so restricting allow_patterns to model_index.json, FL2VA/* and Ref2VA/* is the difference between 196 GB and considerably more. If you only intend to run text-to-video and frame conditioning, drop Ref2VA/* and halve it again.

Step 6: Confirm the GPU Topology

Check the fabric before you serve. It takes one second, and it decides two environment variables in the next step.

Terminal, on the nodeSHELL
# What the driver reports about how the four cards are wired together
nvidia-smi topo -m

# And what CUDA will actually permit between them
python3 -c "
import torch
for i in range(4):
    for j in range(4):
        if i != j:
            print(i, j, torch.cuda.can_device_access_peer(i, j))
"

If every pair reads PHB and can_device_access_peer reports False, as on ours, keep NCCL_P2P_DISABLE=1 and NCCL_CUMEM_ENABLE=0 in the launch below. If you see NV12 and True, you are on an NVLink flavour and can leave both out. The section above explains the difference.

Step 7: Launch MiniMax H3

The base SGLang image does not ship the diffusion extra, so it is installed from the bundled source at container start, which is exactly what the cookbook Docker form does. The two environment variables from step 6 go on the docker run line, and /ephemeral/media is mounted read-only so that file conditioning resolves later without any upload step.

serve_mmh3.sh, on the nodeSHELL
#!/bin/bash
# Serve MiniMax-H3 on a single 4x NVIDIA H100 node.
# The two NCCL variables suit a PCIe fabric; on an NVLink flavour you can leave them out.
#
# Usage: serve_mmh3.sh [variant]    variant = fl2va (default, serves t2va + fl2va) | ref2va
VARIANT=${1:-fl2va}

docker rm -f mmh3 2>/dev/null || true
docker run -d --name mmh3 --gpus all --shm-size 32g --ipc=host --network host \
  --ulimit memlock=-1 --ulimit stack=67108864 \
  -v /ephemeral/hf:/root/.cache/huggingface \
  -v /ephemeral/media:/data/minimax-h3:ro \
  --env "HF_TOKEN=$(cat $HOME/hf_token.txt)" \
  --env HF_HUB_ENABLE_HF_TRANSFER=1 \
  --env NCCL_P2P_DISABLE=1 \
  --env NCCL_CUMEM_ENABLE=0 \
  lmsysorg/sglang:dev \
  bash -lc 'python -m pip install -e "/sgl-workspace/sglang/python[diffusion]" && exec sglang serve "$@"' -- \
    --model-path MiniMaxAI/MiniMax-H3 \
    --model-variant "$VARIANT" \
    --num-gpus 4 \
    --tp-size 4 \
    --ulysses-degree 1 \
    --performance-mode speed \
    --host 0.0.0.0 \
    --port 30010

echo "launched MiniMax-H3 variant=$VARIANT on 4x NVIDIA H100"

Three flags shape what the server does. --model-variant fl2va serves both t2va and fl2va from one checkpoint partition, so nine of our eleven clips came from a single server. ref2va is a separate partition and needs a restart, which took about seven minutes including the diffusion extra install. --performance-mode speed deliberately keeps the DiT eager: the cookbook does not recommend torch.compile here because it changes numerical output for a negligible gain.

Step 8: Verify the Deployment

Confirm the pipeline end to end before committing four minutes to a full generation. Eight steps at four seconds is enough to exercise every stage.

Terminal, on the nodeSHELL
# Eight steps and four seconds, purely to prove the pipeline runs end to end
JOB=$(curl -s -X POST http://127.0.0.1:30010/v1/videos \
  -H "Content-Type: application/json" \
  -d '{"model": "MiniMaxAI/MiniMax-H3",
       "prompt": "integrated_multimodal_description: [Shot 1] A violet ink drop blooms in clear water. The camera holds a static shot throughout.\noverall_soundscape: A single soft plink, then a quiet liquid swirl.\nnon_diegetic_music: One sustained synthesiser pad, no percussion.",
       "seconds": 4, "task": "t2va", "conditions": [],
       "target": {"short_edge": 768, "aspect_ratio": "1:1", "duration_seconds": 4.0},
       "num_inference_steps": 8, "seed": 9233}' | jq -r .id)

until [ "$(curl -s http://127.0.0.1:30010/v1/videos/$JOB | jq -r .status)" = "completed" ]; do
  sleep 4
done

curl -s http://127.0.0.1:30010/v1/videos/$JOB/content -o /ephemeral/out/smoke.mp4
ffprobe -v error -show_entries stream=codec_name,width,height,sample_rate,channels,nb_frames \
  -of csv=p=0 /ephemeral/out/smoke.mp4
ffprobe on the smoke test outputOUTPUT
h264,768,768,107
aac,32000,2,141

Two codecs, one file, 32000 Hz, two channels, back in 16.1 seconds. The 107 frames matter too: H3 quantises duration to a legal frame count, so a request for four seconds becomes 4.46 seconds.

Driving the Asynchronous Video Endpoint

H3 does not stream. A generation takes minutes, so SGLang exposes it as an OpenAI-shaped asynchronous job: submit, poll, then fetch the bytes. Three endpoints cover the whole surface.

Call Returns Notes
POST /v1/videos { "id": ... } Accepts the prompt, the task, the target geometry and the sampler settings
GET /v1/videos/{id} { "status": ... } Poll every few seconds until the status reaches completed
GET /v1/videos/{id}/content The MP4 itself One file, with the video and the audio already muxed together

The request body carries the decisions that shape the output. This is the exact shape we sent for every text-to-video clip.

POST /v1/videos, request bodyJSON
{
  "model": "MiniMaxAI/MiniMax-H3",
  "prompt": "<the structured prompt, in full>",
  "seconds": 6,
  "task": "t2va",
  "conditions": [],
  "target": {
    "short_edge": 768,
    "aspect_ratio": "16:9",
    "duration_seconds": 6.0
  },
  "num_outputs_per_prompt": 1,
  "num_inference_steps": 50,
  "flow_shift": 12.0,
  "audio_flow_shift": 3.0,
  "seed": 9233
}
Field Value we used What it controls
task t2va, fl2va, ref2va Which conditioning path runs. The server has to be serving the matching partition
target.short_edge 768 The only self-hosted option. 2K needs H3-Regenerate-2K, which is not open
target.aspect_ratio 21:9 to 1:1 Sets the long edge from the short edge, and therefore the sequence length
num_inference_steps 50 The reference accuracy setting. This is the single biggest lever on wall clock
flow_shift 12.0 Video sampler shift, at the value the vLLM recipe gives as reference
audio_flow_shift 3.0 The audio stream gets its own shift, and it is not the same number
seed One per clip Fixed per clip, so a prompt edit can be attributed to the prompt

Conditioning is passed as a list. An empty list is text-to-video; an image with the keyframe role and frame_index: 0 anchors the first frame; a video or an image with the reference role drives Ref2VA. Every URI is a file:// path resolved inside the server container, which is why the media directory is bind mounted read-only at /data/minimax-h3.

mmh3_generate.py, condition constructionPYTHON
def build_conditions(task, spec):
    """MEDIA is the read-only mount inside the server container, /data/minimax-h3."""
    if task == "t2va":
        return []
    if task == "fl2va":
        img = f"{MEDIA}/{spec['source']}_frame.png"
        return [{"type": "image", "uri": "file://" + img,
                 "role": "keyframe", "frame_index": 0}]
    if task == "ref2va":
        if spec.get("source_video"):
            return [{"type": "video", "uri": f"file://{MEDIA}/{spec['source_video']}.mp4",
                     "role": "reference", "start_time_seconds": 0.0}]
        return [{"type": "image", "uri": f"file://{MEDIA}/{spec['source']}_frame.png",
                 "role": "reference"}]
    raise ValueError(task)

The client is straightforward: submit, poll on a four second interval, download, then probe the result with ffprobe and write a JSON record holding the exact request, the job id, the wall clock and the measured media properties. Those records are where every timing and every media property quoted here comes from.

mmh3_generate.py, one generation end to endPYTHON
BASE = "http://127.0.0.1:30010"

def run_one(task, spec, steps):
    body = {
        "model": "MiniMaxAI/MiniMax-H3",
        "prompt": spec["prompt"],
        "seconds": spec["seconds"],
        "task": task,
        "conditions": build_conditions(task, spec),
        "target": {"short_edge": 768, "aspect_ratio": spec["ratio"],
                   "duration_seconds": float(spec["seconds"])},
        "num_outputs_per_prompt": 1,
        "num_inference_steps": steps,
        "flow_shift": 12.0,
        "audio_flow_shift": 3.0,
        "seed": spec["seed"],
    }
    t0 = time.time()
    job = post("/v1/videos", body)
    vid = job.get("id")

    while True:
        time.sleep(4)
        st = get(f"/v1/videos/{vid}")
        status = st.get("status")
        if status in ("completed", "succeeded"):
            break
        if status in ("failed", "cancelled", "error"):
            return {"id": spec["id"], "error": st}
    gen_s = time.time() - t0

    mp4 = os.path.join(OUT, f"{spec['id']}.mp4")
    with urllib.request.urlopen(f"{BASE}/v1/videos/{vid}/content", timeout=600) as r, \
         open(mp4, "wb") as f:
        f.write(r.read())

    # ffprobe every file, so the record holds measured properties
    return {"id": spec["id"], "task": task, "generation_seconds": round(gen_s, 1),
            "num_inference_steps": steps, "request": body, "job_id": vid,
            "media": probe(mp4), "mp4": mp4}
💡

Probe every file as it lands. A generation that returns HTTP 200 has still told you nothing about whether the audio stream is present, stereo, or at the right sample rate. One ffprobe call per clip turns the whole run into evidence, and it costs nothing next to the four minutes that produced the file.

Prompting Is the Entire Quality Lever

Because H3-Context-IR is not part of the open release, nothing sits between what you type and what the transformer reads, so a vague prompt stays vague. MiniMax anticipated this and published the specification its own service writes to, as a set of prompt-writing skills in the MiniMax-H3 repository. Writing to that specification is not a style preference, it is the deployment.

The specification has two shapes. Text-to-video and frame conditioning take exactly three sections. Reference conditioning takes exactly six.

Task Sections, in this order What to keep in mind
T2VA and FL2VA integrated_multimodal_description, overall_soundscape, non_diegetic_music The soundscape is one to four sentences, the music one to three. FL2VA must open with the first-frame anchor sentence before anything else
Ref2VA subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music Every reference gets a label such as <Video 1> or <Picture 1>, and every label needs a retention marker

The four rules that carry the most weight

  • Sound is a first-class section, not an afterthought. Splitting diegetic sound from non-diegetic music is what lets the model place a bell motif in the score and a fabric rustle in the room at the same time.
  • Camera motion has a canonical phrasing: motion type, then amplitude, then speed. "The camera performs a tracking shot forward with medium amplitude at slow speed" is understood. "Slow dolly in" is a guess.
  • First-frame conditioning has a mandatory opening sentence. "For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced." Without it the supplied frame is treated as loose inspiration rather than as frame zero.
  • Dialogue is tagged, and speakers are stable. The speaker is described outside the tag and the words go inside it with a language label, so the same identifier keeps the same voice across the clip.
Dialogue, in the official formPROMPT
A composed male presenter in his thirties (S1) says, <d>[English] Every frame you are
watching, and every sound, came out of one model in a single pass.</d>

Writing a brand palette into the pixels

Every clip here is in the Hyperstack palette, and none of it was colour graded afterwards. The palette was written into every prompt in natural language, so H3 rendered those colours directly. Hex codes mean nothing to a video model. Colour names do.

Brand token Colour What the prompt says
Primary purple  #9233e9 "electric violet"
Secondary indigo  #534ab7 "deep royal indigo"
Deep purple  #7c2bd0 "rich amethyst"
Bright accent  #bc3ae9 "luminous orchid"
Gradient  135deg "a smooth 135-degree gradient running from electric violet at the upper left to deep royal indigo at the lower right"
Light tints  #f3eaff "pale lilac-white bloom"

The clause that does the most work is the negative one. Every prompt ends its visual section with the same sentence: a strict colour palette of electric violet, deep royal indigo and rich amethyst, with luminous orchid highlights and pale lilac-white bloom; no warm oranges, no greens and no reds anywhere in frame. Stating what must not appear is what holds the palette steady across eleven independent generations with eleven different seeds.

The full prompt behind clip 01, in the official three-section formPROMPT
integrated_multimodal_description: [Shot 1] Abstract macro cinematography with a tight,
centred composition and shallow depth of field. A single sheet of weightless liquid silk
fills the frame against a seamless near-black background. Its surface carries a smooth
135-degree gradient running from electric violet at the upper left to deep royal indigo
at the lower right. The silk folds and unfurls continuously in slow motion; rich amethyst
shadows pool inside each fold while luminous orchid catch-lights trace the rolling edges,
and a pale lilac-white bloom haloes the brightest crests. The camera holds a static shot
throughout, letting the fabric move within the frame. Rendered with a strict colour
palette of electric violet, deep royal indigo and rich amethyst, with luminous orchid
highlights and pale lilac-white bloom; no warm oranges, no greens and no reds anywhere
in frame.
overall_soundscape: A soft, airy low-frequency swell of fabric moving through still air
in a large quiet room. A faint high shimmer rises each time the light catches a fold.
There are no voices and no mechanical noise.
non_diegetic_music: Minimal ambient electronic score: one slow sustained synthesiser pad
in a major key with a single soft bell motif every few seconds and gentle sub-bass, no
percussion.
📘

Abstract scenes only. MiniMax publishes a brand-promotion skill alongside the prompt-writing one, and it prohibits drawing logos, wordmarks and product interfaces. Everything here is motion, atmosphere and conceptual imagery, which is the correct way to generate brand material with a video model in any case: an approximated logo is worse than no logo.

Confirming the structure before you spend GPU time on it

The specification is easy to hold to once it is encoded. Two small helpers build the prompts in the official order, and a block of assertions checks every one of them at import, so the structure is confirmed in a second rather than inferred from the finished video four minutes later.

mmh3_prompts.py, prompt constructorsPYTHON
PALETTE = ("a strict colour palette of electric violet, deep royal indigo and rich amethyst, "
           "with luminous orchid highlights and pale lilac-white bloom; no warm oranges, "
           "no greens and no reds anywhere in frame")

# The official opener, required whenever a real first frame is supplied
FIRST_FRAME_ANCHOR = ("For the target video, at 0.00 seconds into the target video, "
                      "<Picture 1> (from [Shot 1]) is fully referenced.")


def P(desc, sound, music):
    """T2VA and FL2VA: exactly three sections, in the official order."""
    return (f"integrated_multimodal_description: {desc}\n"
            f"overall_soundscape: {sound}\n"
            f"non_diegetic_music: {music}")


def R(subjects, summary, retention, detail, sound, music):
    """Ref2VA: exactly six sections, in the official order."""
    return (f"subject_definitions:\n{subjects}\n\n"
            f"summary:\n{summary}\n\n"
            f"retention_analysis:\n{retention}\n\n"
            f"detailed_description:\n{detail}\n\n"
            f"overall_soundscape:\n{sound}\n\n"
            f"non_diegetic_music:\n{music}")
mmh3_prompts.py, specification self-checkPYTHON
# Run at import, so the structure is confirmed before any GPU time is spent.
for p in T2VA + FL2VA:
    assert p["prompt"].startswith("integrated_multimodal_description:"), p["id"]
    assert "\noverall_soundscape:" in p["prompt"], p["id"]
    assert "\nnon_diegetic_music:" in p["prompt"], p["id"]

for p in REF2VA:
    for section in ["subject_definitions:", "summary:", "retention_analysis:",
                    "detailed_description:", "overall_soundscape:", "non_diegetic_music:"]:
        assert section in p["prompt"], (p["id"], section)

for p in FL2VA:
    assert "<Picture 1>" in p["prompt"] and "0.00 seconds" in p["prompt"], p["id"]

This is the cheapest quality control in the whole pipeline. On a node costing $8.00 per hour, confirming the structure at import rather than after generation is worth roughly sixty cents of GPU time each time it runs.

Eleven Clips Across Three Task Modes

Every reference image and every reference clip used below came out of this same run. Nothing here is stock footage. After the seven text-to-video clips finished, we pulled one frame out of four of them at roughly forty per cent in, past the opening ramp where the composition has settled, and used those frames to drive the conditioned modes.

The chain: text to video, video to frame, frame back to video

Reference material generated by the same model on the same node, so it is photographic, already in palette, and free of any third-party licensing question.

THE CHAINED PIPELINE: EVERY REFERENCE IS SOMETHING WE MADE FIRSTT2VASeven clips from textalone, no conditioningfl2va checkpointffmpegOne real frame pulledfrom 40 per cent inalready in paletteFL2VA and Ref2VAFour clips conditioned onthat frame or that clipref2va needs a restartNo stock footage, no licensing question, and every reference frame is already the right colour.

The frame extraction step is a single ffmpeg call per clip, run on the node.

mmh3_extract_frames.sh, on the nodeSHELL
#!/bin/bash
# Pull frames out of our own T2VA output, to use as reference material.
# Frames are taken ~40% into each clip, past the opening ramp, where the composition
# has settled. /ephemeral/media is mounted read-only in the server at /data/minimax-h3.
set -u
OUT=/ephemeral/out
MEDIA=/ephemeral/media
mkdir -p "$MEDIA"

extract () {
  local src="$OUT/$1.mp4"
  local dur at
  dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$src")
  at=$(python3 -c "print(round(float('$dur') * 0.4, 2))")
  ffmpeg -y -v error -ss "$at" -i "$src" -frames:v 1 "$MEDIA/$1_frame.png"
}

for id in "$@"; do extract "$id"; done

# Ref2VA video conditioning needs the clip itself visible inside the container too
for id in "$@"; do cp -f "$OUT/$id.mp4" "$MEDIA/$id.mp4"; done
The full run, in orderSHELL
# T2VA and FL2VA are both served by the fl2va checkpoint, so these run back to back
python3 mmh3_generate.py --task t2va
bash mmh3_extract_frames.sh 01_brand_hero 02_gpu_datacentre 03_data_viz 04_aurora
python3 mmh3_generate.py --task fl2va

# Ref2VA is a separate checkpoint partition, so the server has to be restarted
bash serve_mmh3.sh ref2va
python3 mmh3_generate.py --task ref2va

Text to video and audio

Seven clips, no conditioning of any kind, straight from the three-section prompt. Each one has sound generated in the same pass as the picture, so unmute them.

Liquid gradient silk

TASKt2vaSIZE1344x768LENGTH6.58 sGEN272.2 s

A sheet of weightless silk carrying the 135-degree brand gradient, folding in slow motion against near-black. The soundtrack is a fabric swell with a soft bell motif over a sustained pad.

GPU data-centre aisle

TASKt2vaSIZE1344x768LENGTH6.58 sGEN272.2 s

A forward tracking shot with medium amplitude at slow speed down a symmetrical aisle. The volumetric haze and the reflected floor came from the prompt, not from a render pass.

Data visualisation materialising

TASKt2vaSIZE1344x768LENGTH6.58 sGEN272.2 s

An arc shot around glass bar-chart columns that grow in sequence. This clip later became the style reference for clip 11.

Violet aurora over dark peaks

TASKt2vaSIZE1536x672LENGTH8.00 sGEN356.2 s

The widest geometry in the set at 21:9 and eight seconds, which makes it the most expensive clip we generated. Note the aurora reflected in the lake, which no part of the prompt describes twice.

Violet ink bloom, macro

TASKt2vaSIZE768x768LENGTH5.17 sGEN104.1 s

Square, five seconds, and the cheapest clip in the set at 104.1 seconds. Fewer pixels and fewer frames mean a shorter sequence, and the denoising loop scales with it directly.

Refracting glass prisms

TASKt2vaSIZE1344x768LENGTH5.17 sGEN200.1 s

An arc shot with medium amplitude at slow speed around three prisms, with caustics thrown across the floor. The audio is glassy interface tones rather than music.

Speech, lip sync and a generated voice

The same three-section prompt, with one dialogue line tagged in the official form. H3 produced the speech, the lip movement and the voice timbre together with the picture, in the same pass, on the same 50 steps. Nothing here was dubbed.

TASKt2vaSIZE1344x768LENGTH6.58 sGEN276.2 s

A studio presenter delivering one tagged line. The voice, the lip sync and the rim lighting are all model output from a single prompt.

This clip cost 276.2 seconds against 272.2 for the three silent 16:9 clips of the same geometry. Adding speech, lip sync and a voice to a generation added four seconds, or about 1.5 per cent. The audio stream is cheap next to the video stream it is synchronised with.

First frame to video and audio

Both clips below start from a PNG pulled out of the text-to-video output above, passed as a keyframe condition at frame_index: 0, with the mandatory anchor sentence at the top of the prompt. The task is continuation: hold the composition, keep the motion going.

The hero clip continues from its own frame

TASKfl2vaSIZE1344x768LENGTH5.17 sGEN212.2 s

Conditioned on a frame taken 40 per cent into clip 01. The gradient keeps sliding and the folds keep forming from exactly where the reference left them.

The aurora continues from its own frame

TASKfl2vaSIZE1536x672LENGTH5.17 sGEN212.1 s

Conditioned on a frame from clip 04, at 21:9. The peaks hold as a static silhouette while the curtains keep moving, which is what the prompt asked for explicitly.

Reference to video and audio

Ref2VA is the most capable mode and the most expensive one. It is also a separate checkpoint partition, so the server has to be restarted with --model-variant ref2va before either of these will run. The prompt shape changes completely: six sections, labelled references, and a retention marker on every label saying how much of it should survive.

Video to video: clip 02, restyled

TASKref2vaSIZE1344x768LENGTH5.17 sGEN600.4 s

The data-centre aisle from clip 02 as a reference video, restyled into glowing monoliths. The forward camera motion and the aisle symmetry are preserved, the surfaces and the light are not.

The retention markers are doing the work here. <Video 1> is marked partially_preserved, which keeps the motion and the pacing. <Subject 1>, the racks, is marked attribute_transfer, which keeps their position and rhythm along the aisle while changing what they are made of. The markers are the dial: fully_preserved holds a reference exactly, weak_reference keeps only its atmosphere, and the two in between set how much of it carries across.

The full six-section prompt behind clip 10PROMPT
subject_definitions:
<Video 1> is the source video for the editing task: a forward tracking shot down the
centre of a modern GPU data-centre aisle lit by vertical strips of electric violet light.
<Subject 1> is the double row of tall black server racks receding toward a vanishing
point in <Video 1>.

summary:
[video editing + reference generation] The target video restyles <Video 1> into a
futuristic night-time control room. <Subject 1> is transformed from server racks into
tall glowing monoliths while the original forward camera motion, aisle symmetry and
timing are preserved.

retention_analysis:
<Video 1> (source video editing): partially_preserved - the forward tracking motion, the
symmetrical aisle layout and the overall pacing are retained, while the surfaces, haze
density and light intensity are restyled.
<Subject 1> (appears in [Shot 1]): attribute_transfer - the racks keep their position,
scale and rhythm along the aisle but take on the appearance of smooth glowing monoliths.

detailed_description:
The target video is in a sleek, high-contrast, photoreal science-fiction style with heavy
volumetric atmosphere.
[Shot 1] Cinematic wide symmetrical composition down the centre of a darkened control-room
corridor. The two facing rows from <Subject 1> now read as tall, smooth, edge-lit monoliths
whose vertical seams glow electric violet. Denser volumetric haze fills the corridor so each
seam casts a hard directional beam across the floor, and the polished surface below mirrors
the light into long deep royal indigo streaks. Scattered luminous orchid indicator points
pulse slowly along the monolith faces, and a pale lilac-white light source marks the far end
of the corridor. The camera performs a tracking shot forward with medium amplitude at slow
speed, matching the motion of <Video 1>. Rendered with a strict colour palette of electric
violet, deep royal indigo and rich amethyst, with luminous orchid highlights and pale
lilac-white bloom; no warm oranges, no greens and no reds anywhere in frame.

overall_soundscape:
A deep mechanical hum with a wide cavernous reverberation fills the corridor, layered with
a fine airy hiss of moving air and occasional low electronic pulses.

non_diegetic_music:
Low pulsing synth ostinato with sparse metallic percussion, darker and more spacious than
the source.

Image reference: a style carried onto a new subject

TASKref2vaSIZE1344x768LENGTH5.17 sGEN312.2 s

A frame from clip 03 supplied as a style reference only, marked weak_reference. The bar-chart geometry does not appear: the glass material, the colour system and the particle atmosphere carry across to a network graph instead.

weak_reference is the marker that makes this work. It tells the model to take the material treatment, the lighting and the colour system, and to leave the subject behind. The same image marked fully_preserved would have produced bar charts again.

Verifying all eleven

One loop over the output directory confirms what every file contains.

Terminal, on the nodeSHELL
for f in /ephemeral/out/*.mp4; do
  echo "$(basename $f) -> $(ffprobe -v error \
    -show_entries stream=codec_name,width,height,r_frame_rate,nb_frames,sample_rate,channels \
    -of csv=p=0 "$f" | tr '\n' ' ')"
done
ffprobe across every generated fileOUTPUT
01_brand_hero.mp4      -> h264,1344,768,24/1,158  aac,32000,2
02_gpu_datacentre.mp4  -> h264,1344,768,24/1,158  aac,32000,2
03_data_viz.mp4        -> h264,1344,768,24/1,158  aac,32000,2
04_aurora.mp4          -> h264,1536,672,24/1,192  aac,32000,2
05_studio_dialogue.mp4 -> h264,1344,768,24/1,158  aac,32000,2
06_ink_bloom.mp4       -> h264,768,768,24/1,124   aac,32000,2
07_glass_prisms.mp4    -> h264,1344,768,24/1,124  aac,32000,2
08_hero_continue.mp4   -> h264,1344,768,24/1,124  aac,32000,2
09_aurora_continue.mp4 -> h264,1536,672,24/1,124  aac,32000,2
10_v2v_restyle.mp4     -> h264,1344,768,24/1,124  aac,32000,2
11_image_ref_style.mp4 -> h264,1344,768,24/1,124  aac,32000,2

Every file is H.264 and AAC, at 24 frames per second, 32000 Hz, two channels. Look at that column of frame counts: 158, 192 and 124 are all of the form 17n + 5, which is exactly the frame quantisation the vLLM recipe documents. A request for six seconds becomes 158 frames, which is 6.58 seconds, and the extra 0.58 seconds is the model rounding up to the nearest legal length.

Where the Generation Time Goes

Eleven clips, 3,090.1 seconds of generation, 65.3 seconds of finished video. That is about 47 seconds of four-GPU wall clock for every second you end up with. The server log shows exactly where that time goes.

Server log, the video-reference Ref2VA jobOUTPUT
[08-11 03:53:08] Running pipeline stages: ['InputValidationStage',
  'MiniMaxH3PartitionAdmissionStage', 'MiniMaxH3TextEncodingStage',
  'MiniMaxH3VisualEncodingStage', 'MiniMaxH3AudioEncodingStage',
  'MiniMaxH3LatentPreparationStage', 'MiniMaxH3TimestepPreparationStage',
  'MiniMaxH3DenoisingStage', 'MiniMaxH3DecodingStage']
[08-11 03:53:16] [MiniMaxH3VisualEncodingStage]     finished in 5.8935 seconds
[08-11 03:53:16] [MiniMaxH3AudioEncodingStage]      finished in 0.3196 seconds
[08-11 03:53:16] [MiniMaxH3LatentPreparationStage]  finished in 0.0214 seconds
[08-11 03:53:16] [MiniMaxH3TimestepPreparationStage] finished in 0.0003 seconds
minimax_h3 denoise: 100%|##########| 49/49 [09:35<00:00, 11.75s/it]
[08-11 04:02:52] [MiniMaxH3DenoisingStage]          finished in 575.9068 seconds
[08-11 04:03:06] [MiniMaxH3DecodingStage]           finished in 2.0632 seconds
[08-11 04:03:07] Peak memory usage: 54888.00 MB

Where a generation spends its time

Two Ref2VA jobs on the same server, minutes apart. Encoding, latent preparation and VAE decoding barely register against the denoising loop.

 

Stage timings copied from the SGLang server log. Both jobs ran 50 inference steps at 1344x768.

Denoising is 98.6 per cent of server-side time in both jobs. Everything else together is under nine seconds. That leaves two settings worth tuning: the number of inference steps, and the length of the sequence being denoised. Nothing else moves the needle.

The two jobs also differ by exactly one thing, and it shows. The video-reference job ran at 11.75 seconds per step; the image-reference job, at the same resolution and the same step count, ran at 6.08. A reference video is decoded, encoded and then carried in the attention sequence for every one of the 50 steps, so it nearly doubles the per-step cost. It also lifts peak memory from 46,020 MB to 54,888 MB.

Sequence length is the price, not resolution

Two of the clips isolate frame count directly. A 1344x768 frame and a 1536x672 frame contain exactly the same 1,032,192 pixels, so the only difference between clip 01 and clip 04 is the frame count: 158 against 192. Twenty-two per cent more frames cost thirty-one per cent more time.

Generation time against the size of the output

Four text-to-video clips, all at 50 steps. The bars are measured wall clock. The line is the same numbers divided by the megapixel-seconds of finished video, and it climbs.

 

Every figure measured on 4x NVIDIA H100 80GB PCIe. Megapixel-seconds is width x height x duration, which tracks the visual token count the transformer has to attend over.

If cost were linear in sequence length that line would be flat. It climbs from 34.1 to 43.1 seconds per megapixel-second instead, because this release ships inference with full attention only: the initial open source drop is full attention, and the sparse attention implementation MiniMax trained with is published separately at a later date. Until it lands, doubling the length of a clip costs more than twice as much.

What each task mode costs

The four clips below are the cleanest comparison in the run: identical geometry at 1344x768, identical duration at 5.17 seconds, identical 50 steps. Only the conditioning path changes.

The same clip length through four different conditioning paths

Frame conditioning is nearly free. An image reference costs half as much again. A reference video triples it.

 

Clips 07, 08, 11 and 10 respectively, all at 1344x768 and 5.17 seconds with 50 inference steps.

Task Conditioning Generation Against text to video
t2va None 200.1 s baseline
fl2va One PNG at frame zero 212.2 s +6 per cent
ref2va One reference image 312.2 s +56 per cent
ref2va One reference video 600.4 s 3.0x
💡

Budget reference video carefully. Ref2VA accepts up to three clips totalling fifteen seconds. Our single five second reference already tripled the cost of the generation. Plan a Ref2VA batch around that multiplier rather than around the text-to-video numbers, and keep reference clips as short as the shot allows.

How four NVIDIA H100 GPUs compare

The vLLM recipe publishes measured end-to-end figures for the same model on other four-GPU configurations. Normalising all of them to megapixel-seconds of finished video makes them roughly comparable, with the caveat that the NVIDIA B300 figure is a first-frame job at 1248x768 while the other two are text to video at 1344x768.

Four GPUs, three generations of hardware

Lower is faster. This is wall clock divided by the megapixel-seconds of video produced, so clips of different lengths can sit on the same axis.

 

NVIDIA B300 and AMD Instinct MI300X figures from the official vLLM recipe. The NVIDIA H100 figure is our own, on the SGLang path at 50 steps.

NVIDIA H100 is roughly four times the wall clock of NVIDIA B300 on this workload, which is what you would expect from a diffusion transformer that is almost entirely attention and feed-forward compute in BF16. It is also a good deal cheaper per GPU hour, at $2.00 on Spot against $7.40 for NVIDIA B300 on demand, so the cost per finished clip is much closer than the latency suggests. If you want faster iteration on NVIDIA H100 rather than faster hardware, the lever is step count: there is a community Turbo LoRA that distils first-and-last-frame generation down to four steps instead of fifty, with a distillation recipe behind it. We ran the full 50-step path throughout, on the released weights as published.

What the Run Cost

Four NVIDIA H100 80GB PCIe cards on Spot are $2.00 per GPU per hour, so the node runs at $8.00 per hour, or 0.22 cents per second. That makes the cost of every clip a simple multiplication.

What each clip cost to generate

Generation time multiplied by $8.00 per hour. This is GPU time only, and excludes the download and the two server starts.

CHEAPEST CLIP
$0.23
104.1 s, the square macro clip at 768x768
TYPICAL 16:9 CLIP
$0.60
272.2 s, six seconds at 1344x768
MOST EXPENSIVE CLIP
$1.33
600.4 s, the video-to-video restyle
ALL ELEVEN CLIPS
$6.87
3,090.1 s of generation across three task modes

Rates from the Hyperstack GPU pricing page at the time of the run. Spot capacity can be reclaimed, so a long batch should checkpoint its results to somewhere other than the node.

The session as a whole is a different number, because the 196 GB download and two server starts are on the clock as well. End to end, from an empty node to eleven verified clips, took about two hours.

Phase Wall clock Node cost
Deploy the node from the dashboard and prepare the disks about 3 min $0.40
Pull the container image and 196 GB of weights about 48 min $6.40
Start the server, twice, once per checkpoint partition about 14 min $1.87
Generate eleven clips, measured 51.5 min $6.87
Total, end to end about 2 hours about $16

The download is the single largest line, and it is the one that does not get cheaper on faster GPUs. Everything after it is minutes, which means the marginal cost of another clip is measured in cents once the node is warm.

💡

Copy each clip off as it lands. The ephemeral disk is runtime storage that lives with the virtual machine, so treat /ephemeral/out as a working directory and pull finished clips down with scp as you go. It also keeps the review loop tight, because you can watch clip 03 while clip 04 is still generating.

Why Deploy MiniMax H3 on Hyperstack?

Hyperstack is a cloud platform built for AI and machine learning workloads. Here is why it suits a generative video model specifically:

Four NVIDIA H100 GPUs on One Node
H3 needs about 135 GiB resident for one task partition, which is four cards, and it needs them on one machine. The NVIDIA H100 80GB PCIe flavour gives exactly that, with 124 vCPUs and 720 GB of RAM behind it for the decode path.
3.2 TB of NVMe Where the Weights Go
Two checkpoint partitions and a container image do not fit on a 96 GB root disk. The ephemeral NVMe disk at /ephemeral took the 196 GB download, the media directory and every generated MP4 with room to spare.
Matched CUDA, Driver and Docker Images
The Ubuntu 24.04 R570 CUDA 12.8 with Docker image ships the driver and the container runtime already matched to the SGLang image, so the deployment goes from ssh to docker run with no driver work at all.
Spot Pricing and Per-Minute Billing
Spot VMs put this node at $8.00 per hour, and billing is per minute of runtime. A full evaluation run of a video model costs less than a working lunch.
A Choice of Interconnect
This deployment runs on the PCIe flavour, with NCCL configured to match it. When peer-to-peer matters, NVIDIA H100 SXM and the NVLink flavours are one flavor_name away, with no other change to the launch command.
A Route to Faster Hardware
Video diffusion rewards newer silicon more than language models do. NVIDIA H200 SXM at 141 GB per card and NVIDIA Blackwell reservations take the same launch command onto hardware where the same clip takes a quarter of the time.

Generate video with native audio

Run MiniMax H3 on four NVIDIA H100 GPUs

One node on Spot at $8.00 per hour. Our run pulled 196 GB, served both checkpoint partitions and produced eleven clips with synchronised stereo audio for under $16.

4x NVIDIA H100 80GBTP 4 x Ulysses 1768p with 32 kHz stereo$8.00 per hour

Launch an NVIDIA GPU node on Hyperstack today.

FAQs

What hardware do you need to run MiniMax H3?

One task partition is about 135 GiB in BF16, so four GPUs of 80 GB is the practical floor. We ran it on 4x NVIDIA H100 80GB PCIe on a single Hyperstack node, peaking at 46.0 to 54.9 GB per GPU with tensor parallel 4 and Ulysses degree 1.

Can you get 2K video out of the open MiniMax H3 weights?

No. 2K comes from H3-Regenerate-2K, which is not part of the open release, so a self-hosted deployment produces a 768 pixel short edge. Every published aspect ratio from 21:9 to 9:16 works, and sets the long edge from there.

How long does MiniMax H3 take to generate a video on NVIDIA H100?

On 4x NVIDIA H100 80GB at 50 inference steps we measured 104.1 seconds for a five second square clip, 272.2 seconds for six seconds at 1344x768, and 600.4 seconds with a reference video. Denoising is 98.6 per cent of that.

How much does it cost to run MiniMax H3 on Hyperstack?

The 4x NVIDIA H100 80GB PCIe Spot node is $8.00 per hour, or 0.22 cents per second. The eleven clips cost $6.87 of GPU time between them, and the whole session including the 196 GB download came to about $16.

How do you write a MiniMax H3 prompt without H3-Context-IR?

Follow the prompt-writing skill in the model repository. Text-to-video and frame conditioning take exactly three sections in order: integrated_multimodal_description, overall_soundscape and non_diegetic_music. Reference conditioning takes six, and every labelled reference needs a retention marker.

Fareed Khan

Fareed Khan

calendar 11 Aug 2026

Read More
tutorials Tutorials link

Deploying Kimi K3 on NVIDIA H100 GPUs for Efficient Inference

Kimi K3 is Moonshot AI's 2.8 trillion parameter flagship, ...

Kimi K3 is Moonshot AI's 2.8 trillion parameter flagship, and since the weights were published on 27 July 2026 anyone can serve it themselves. It is a sparse Mixture-of-Experts model built on Kimi Delta Attention and Attention Residuals, activating 104 billion parameters through 16 of 896 experts per token, with native vision through MoonViT-V2 and a one million token context window. The checkpoint ships in native MXFP4, and when we pulled it the 96 safetensors shards measured 1,560.94 GB. That one number decides everything about serving it. The official vLLM recipe and the vLLM launch blog both put the floor at Blackwell: at least one 8x NVIDIA B300 node, or an NVIDIA GB300 NVL72, with 16x NVIDIA B200 also supported. NVIDIA H100 is not on that list.

This guide serves Kimi K3 on NVIDIA H100 anyway: 32 NVIDIA H100 80GB GPUs across four Hyperstack nodes, tensor parallel 32, expert parallel 32, at $64 per hour. Every log line, memory figure and timing below comes from a single run on 28 July 2026 that reached a live endpoint eleven minutes after the containers started, then answered chat, called two tools in one turn, read an image and returned its chain of thought, with the full one million token context window intact. If you have read our technical deep dive into Kimi K3, this is the deployment half of that story: the arithmetic that fixes the cluster shape, the launch command, and what the model does once it answers.

Kimi K3 in the Numbers That Decide a Deployment

Before any hardware is booked, five figures from the model card set the shape of the deployment: the parameter count, the activated parameter count, the quantisation format, the layer composition and the context window. Everything else in this guide follows from them.

Inside Kimi K3

Ninety-three layers, sixty-nine of them Kimi Delta Attention and twenty-four Gated MLA, with a Stable LatentMoE block that lights up sixteen of eight hundred and ninety-six experts per token.

2.8T total104B active per token93 layers896 experts, top-161M contextMXFP4 weights, MXFP8 activationsMoonViT-V2 vision96 safetensors shards
CHECKPOINT ON DISK
1,560.94 GB
96 shards, verified byte for byte on all four nodes
WEIGHT FORMAT
MXFP4
quantisation-aware trained from the SFT stage onward
ATTENTION
69 KDA + 24 Gated MLA
Kimi Delta Attention with Attention Residuals
SPARSITY
16 of 896
Stable LatentMoE, 104B of 2.8T active per token
CONTEXT
1,048,576 tokens
reported by the server as max_model_len
VISION
MoonViT-V2, 401M
text and images inside one model

Figures read from the moonshotai/Kimi-K3 model card and from our own download and server logs. The checkpoint size is measured, not estimated. The weights carry Moonshot AI's own Kimi K3 licence rather than a stock open-source licence, so read it before building a commercial service on top.

Two of those rows are worth pausing on. MXFP4 stores roughly four bits per parameter plus a shared block scale, so 2.8 trillion parameters land at about 1.4 TB before overheads, and at 1.56 TB in practice once every tensor and the vision tower are counted. 16 of 896 is an extreme sparsity ratio, and it is why a 2.8 trillion parameter model has a compute cost closer to a 104 billion parameter one. The memory cost, however, is the full 2.8 trillion, because every expert has to be resident somewhere.

What one token actually touches

Kimi K3 is dense in memory and sparse in compute. Both bars are the same model, measured two different ways.

 

The consequence for a deployment: size the cluster from the full bar, and expect the throughput of the sliver. Every one of the 896 experts has to be resident on some GPU, even though only 16 of them run for any given token.

For context on what that memory buys, here is Kimi K3 against the strongest proprietary models on a representative slice of the published results.

Kimi K3 against the leading proprietary models

Six benchmarks spanning coding, agentic browsing, reasoning and document understanding. Higher is better on every row.

Kimi K3 (max)Claude Fable 5 (max, with fallback)GPT 5.6 Sol (max)Claude Opus 4.8 (max)
 

Source: the Kimi K3 release blog benchmark table. See the footnotes there for per-benchmark harness details.

The architecture you are about to shard across 32 GPUs

Kimi K3 is built on two architectural updates. Kimi Delta Attention gives the model an efficient foundation for scaling attention along sequence length, and Attention Residuals retrieve representations selectively across depth rather than accumulating them uniformly. The Mixture-of-Experts block is a Stable LatentMoE, trained stable at this sparsity by four techniques: Quantile Balancing, which derives expert allocation from router-score quantiles instead of a heuristic; Per-Head Muon, which optimises attention heads independently; the Sigmoid Tanh Unit for activation control; and Gated MLA for attention selectivity.

The Kimi K3 architecture

 αwKDAαwStable LatentMoEαwGated MLAαwStable LatentMoEwαBlock n−1Block n−2Block n−3EmbeddingRouterLinear12123NNormLinearShared ExpertRouted ExpertLinearConvL2LinearConvL2LinearConvσσLinearσKimi Delta AttentionNormLinearOutput

The Stable LatentMoE and Kimi Delta Attention modules on the left, the AttnRes operation α at the top right, and the Block Attention Residuals backbone on the right. Source: Kimi K3 release blog, Moonshot AI.

Kimi K3 also produced a motion-graphics explainer of its own architecture, which is worth watching before you shard it across 32 GPUs.

A 3Blue1Brown-style explainer of the Kimi K3 architecture, made by Kimi K3. Video: Moonshot AI, Kimi K3 release blog.

Why 1.56 TB of MXFP4 Does Not Fit Where You Expect

Read this section before provisioning anything, because it decides the shape of the cluster and it is where most of the money goes.

Start with the weights

MXFP4 packs four bits per parameter plus a shared exponent per block, so 2.8 trillion parameters imply roughly 1.4 TB before any overhead. The download settles the question exactly. Our verification script walked the Hugging Face manifest, resolved every symlink and added up the real bytes on disk on each of the four nodes:

Output of verify_k3_download.py, on every nodeOUTPUT
repo             : moonshotai/Kimi-K3
snapshot dir     : /ephemeral/hf/hub/models--moonshotai--Kimi-K3/snapshots/9f62e4e9fffbd0a83ddd60e1c209d828994b3569
safetensors      : 96/96 present
total bytes      : 1,560,936,091,448 (1560.94 GB)
config.json      : OK
.incomplete files: 0
RESULT: COMPLETE

That is 1,560,936,091,448 bytes across 96 shards, identical on all four nodes, and it is the number every later calculation uses. It is worth pausing on the second half of that sentence, because a tensor-parallel deployment does not split the download the way it splits the model.

Ninety-six shards, four times over

Tensor parallelism shards the model across GPUs at load time, but every node still needs the complete checkpoint on its own disk first, because each rank reads its slice of every tensor locally.

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96 safetensors shards, one node1,560.94 GB
 
 
the same download runs on all four nodes at once
PULLED ACROSS THE CLUSTER
6.24 TB
four complete copies, in about 32 minutes of wall clock
SHARDS PER NODE
96
verified present, none incomplete
BYTES PER NODE
1,560.94 GB
measured on disk, not estimated
CLUSTER TOTAL
6.24 TB
four nodes downloading in parallel

The nodes do not wait for each other, so the wall clock is one download rather than four. It is the disk and the network on every node that has to carry the full 1.56 TB.

Now count the GPUs

An NVIDIA H100 "80GB" card is a rounded label. On our nodes nvidia-smi reported 81,559 MiB, which is 79.65 GiB, and the PyTorch allocator counted a total capacity of 79.19 GiB per card. Thirty-two of them come to about 2,534 GiB, or roughly 2.72 TB.

Set 1.56 TB of weights against 2.72 TB of memory and the deployment fits, with about 1.16 TB left for the KV cache, the recurrent state that Kimi Delta Attention needs, the activations, the CUDA context and the communication buffers. That arithmetic is what makes 32 NVIDIA H100 GPUs the right shape for this model, and the measured per-GPU budget later in this guide lands almost exactly where it predicts.

The checkpoint against the cluster

Thirty-two NVIDIA H100 cards, four nodes, one model. The bar is the whole cluster, and the shaded part is what the weights claim before the engine asks for anything.

NODE 0
NODE 1
NODE 2
NODE 3
 
 
 
 
Model weights · 1.56 TB · 57%Everything else · 1.16 TB · 43%
ONE CARD
79.19 GiB
as the PyTorch allocator counts it
THIRTY-TWO CARDS
2,534 GiB
roughly 2.72 TB of cluster memory
WEIGHTS
1,453.7 GiB
the 1,560.94 GB checkpoint, in GiB
LEFT OVER
1,080 GiB
KV cache, state pools, context, buffers

The four node markers are there to make the point that no single node holds the model. A quarter of the shaded region lives on each machine.

Why adding more NVIDIA H100 GPUs does not help

The obvious response to a tight fit is to add nodes. On Kimi K3 that does not work, and the reason is arithmetic rather than budget. Tensor parallelism has to divide the model evenly, which means it has to divide both the 96 attention heads and the 7168 hidden size. The greatest common divisor of 96 and 7168 is 32, so the valid tensor-parallel sizes are 1, 2, 4, 8, 16 and 32, and no more. A tensor-parallel size of 64 would ask for 1.5 attention heads per rank.

Why tensor parallelism has to be exactly 32

Two tests have to pass at once. The size has to divide the 96 attention heads and the 7168 hidden size into whole numbers, and it has to leave the weights under 79.19 GiB per card.

TP 8
96 HEADS ÷ TP
12
7168 ÷ TP
896
WEIGHTS PER CARD
181.7 GiB
divides cleanly, but 8 cards cannot hold it
TP 16
96 HEADS ÷ TP
6
7168 ÷ TP
448
WEIGHTS PER CARD
90.9 GiB
still above 79.19 GiB per card
TP 32
96 HEADS ÷ TP
3
7168 ÷ TP
224
WEIGHTS PER CARD
45.4 GiB
the shape this guide uses
TP 64
96 HEADS ÷ TP
1.5
7168 ÷ TP
112
WEIGHTS PER CARD
n/a
a rank cannot hold half an attention head
GREATEST COMMON DIVISOR OF 96 AND 7168
32
the valid sizes are 1, 2, 4, 8, 16 and 32, and only the largest of them fits on an 80 GB card

Weights per card is the 1,453.7 GiB checkpoint divided evenly, before the engine adds anything of its own. Sizes below 8 divide cleanly too, and put even more on each card.

So TP 32 is not the largest shape that happens to work. It is the only one that clears both tests, and four nodes of eight NVIDIA H100 GPUs is the only way to reach it on this generation. Adding a fifth node cannot lower the per-GPU weight load, because 40 does not divide 96 either, and data parallelism replicates the model rather than sharding it. If TP 32 does not fit, the answer is a larger card, not a longer invoice.

📘

The same divisibility rule is why the published guidance points at NVIDIA B300, NVIDIA GB300 and NVIDIA H200: every entry on those lists carries more than 80 GB per GPU. If you are planning capacity rather than experimenting, NVIDIA H200 SXM at 141 GB per card and NVIDIA Blackwell reservations are the routes that remove this constraint entirely.

One Model, Four Machines: the 32-GPU Topology

Kimi K3 has no sanctioned pipeline-parallel strategy, so a multi-node deployment runs tensor parallelism across the node boundary, with expert parallelism laid over the top. That is a different shape from the one we used for Tencent Hy3, where pipeline parallelism kept the heavy collectives inside each node and put a single activation tensor on the wire. Here every rank participates in every layer, so the topology is flat: thirty-two equal ranks, one process group, one model.

Three things follow from that, and they shape every command in the deployment steps below. All four nodes have to sit in the same environment, because that is what puts them on a shared private network. Collectives are pinned to that private interface with NCCL_SOCKET_IFNAME and run over TCP sockets, since on-demand nodes are joined by ordinary Ethernet rather than InfiniBand. And every node needs its own full copy of the weights on local NVMe, because each rank loads its shard of every tensor from disk.

Thirty-two equal ranks

Every rank holds one thirty-second of every layer and one thirty-second of the experts. Rank 0 additionally runs the HTTP server. There is no head-only and follower-only split in the weights: the work is symmetric.

NODE 0 · HEAD--node-rank 0
GPU
GPU
GPU
GPU
GPU
GPU
GPU
GPU
8x NVIDIA H100 · ranks 0 to 7 · NVLink bridges each card to one neighbour
a quarter of the model resident · 70,960 MiB in use per GPU
serves the OpenAI-compatible API on :8000
 
 
torch.distributed rendezvous on :20000 · NCCL over TCP sockets
Ethernet, no InfiniBand, NCCL_IB_DISABLE=1
NODE 1--node-rank 1
GPU
GPU
GPU
GPU
GPU
GPU
GPU
GPU
8x NVIDIA H100 · ranks 8 to 15
same launch command · 70,960 MiB in use per GPU
NODE 2--node-rank 2
GPU
GPU
GPU
GPU
GPU
GPU
GPU
GPU
8x NVIDIA H100 · ranks 16 to 23
same launch command · 70,960 MiB in use per GPU
NODE 3--node-rank 3
GPU
GPU
GPU
GPU
GPU
GPU
GPU
GPU
8x NVIDIA H100 · ranks 24 to 31
same launch command · 70,960 MiB in use per GPU
World size
32
Tensor parallel
--tp-size 32
Expert parallel
--ep-size 32
Nodes
--nnodes 4

Tensor parallel 32 and expert parallel 32 give a world size of 32. Per-GPU memory is read from nvidia-smi across all four nodes after the server reported ready.

The Kernel Path on Hopper, and the Settings That Fit 1.56 TB

This deployment uses SGLang, which publishes a Kimi K3 cell for four NVIDIA H100 nodes in its deployment cookbook. Two things about that configuration are worth understanding before you run it, because they explain both the flags and the performance.

MXFP4 weights, a mixed-precision kernel

Kimi K3 stores its expert weights in MXFP4, and the MoE backends that execute 4-bit weights natively are built around Blackwell FP4 hardware, which is why the published guidance points at NVIDIA B300 and NVIDIA GB300. On Hopper the expert matrix multiplies run instead through Marlin, the mixed-precision W4A16 kernel that multiplies 16-bit activations by 4-bit weights and hides the dequantisation cost behind the matrix multiply itself. The engine announces the choice on the first line of weight loading:

Head node log, first line of weight loadingOUTPUT
[TP0 EP0] FlashInfer TRTLLM MoE deferred finalize is disabled
          (moe_runner_backend=marlin, quant_method=Mxfp4MoEMethod).

What Marlin does on every expert matrix multiply

Two operands of different widths meet in one kernel. The 4-bit weights never become 16-bit tensors in memory, only inside the multiply, which is what keeps the resident footprint at the size of the checkpoint rather than four times it.

OPERAND A
Expert weights, MXFP4
4 bits per parameter plus a shared scale per block, held on the card exactly as they were downloaded
OPERAND B
Activations, 16 bit
the hidden state arriving from the router for the 16 experts this token selected
 
 
--moe-runner-backend marlin
Marlin W4A16 kernel
Dequantises each block of weights inside the matrix multiply and throws the 16-bit copy away before the next block, so the cost lands on the clock rather than on the memory budget.
 
 
Output: 16-bit activations, combined across the 16 routed experts and passed to the next layer

On Blackwell the same weights are multiplied in FP4 without the dequantisation step at all, which is the difference the throughput section quantifies.

That single line in the log explains the throughput figures later in this guide. It is also why --moe-runner-backend marlin appears in the launch command rather than being left to auto: on Hopper it is the path that runs, so it is worth stating.

Three settings that keep the load inside the budget

Loading 1.56 TB through 32 allocators at once is the part of this deployment that is sensitive to configuration. Three of the settings in the launch command exist for exactly that, and they are the ones to keep if you change anything else:

  • PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True switches the PyTorch caching allocator onto CUDA virtual memory management, so segments grow and shrink in place instead of being pinned as separate cudaMalloc blocks that can never merge. On a load that streams 96 shards through the allocator, that is what keeps the resident footprint close to the size of the weights themselves.
  • --ep-size 32 states expert parallelism explicitly rather than leaving it to be inferred, so each rank holds exactly its own slice of the 896 experts and no more.
  • --mem-fraction-static 0.85 sizes the static pool, and it is the single lever to reach for if you want a larger KV cache or more headroom.

With those in place the load runs to completion, and the head node reports where every gigabyte went. The log arrives in four stages, and each one answers a different question.

First, the cluster forms. The tokeniser loads, then all 32 ranks find each other and agree on the process group. Forty-one seconds for a rendezvous across four machines is normal, and almost no memory has been touched yet:

Head node log, stage 1 of 4: the cluster formsOUTPUT
[2026-07-28 07:39:14] #words: 163840 - BOS ID: 163584 - EOS ID: 163585
[2026-07-28 07:39:14] Applying special tokens cache patch for Kimi tokenizer
[2026-07-28 07:39:14 TP0 EP0] Init torch distributed begin.
[2026-07-28 07:39:58 TP0 EP0] Init torch distributed ends. elapsed=41.49 s, mem usage=0.47 GB

Then the weights load. This is the long part, and the middle line is the kernel choice from the section above. Note the pair of numbers at either end: 78.23 GB available before, 18.60 GB after, so the weights themselves cost 59.63 GB per card:

Head node log, stage 2 of 4: the weights loadOUTPUT
[2026-07-28 07:39:58 TP0 EP0] Load weight begin. avail mem=78.23 GB
[2026-07-28 07:39:58 TP0 EP0] FlashInfer TRTLLM MoE deferred finalize is disabled
     (moe_runner_backend=marlin, quant_method=Mxfp4MoEMethod).
[2026-07-28 07:45:16 TP0 EP0] Load weight end. elapsed=317.50 s,
     type=KimiK3ForConditionalGeneration, quant=compressed-tensors,
     avail mem=18.60 GB, mem usage=59.63 GB.

Next the memory pools. The KV cache is only 3.62 GB, but available memory falls by 7.03, and the difference is the recurrent state that Kimi Delta Attention keeps per sequence. The attention backends are chosen here too, one for decode and a different one for prefill:

Head node log, stage 3 of 4: the memory poolsOUTPUT
[2026-07-28 07:45:18 TP0 EP0] KV Cache is allocated. dtype: torch.bfloat16,
     #tokens: 140352, KV size: 3.62 GB
[2026-07-28 07:45:18 TP0 EP0] Memory pool end. avail mem=11.57 GB
[2026-07-28 07:45:18 TP0 EP0] Using hybrid attention backend for decode and prefill:
     decode_backend=flashmla, prefill_backend=fa3.

Finally the server comes up. The line to read is the first one, because it is where the engine states what it will actually accept:

Head node log, stage 4 of 4: the server comes upOUTPUT
[2026-07-28 07:49:32 TP0 EP0] max_total_num_tokens=140352, chunked_prefill_size=8192,
     max_prefill_tokens=16384, max_running_requests=49, context_len=1048576,
     available_gpu_mem=10.34 GB
[2026-07-28 07:49:32] INFO:     Application startup complete.
[2026-07-28 07:49:32] INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
[2026-07-28 07:50:15] The server is fired up and ready to roll!

Put those four stages together and the per-GPU budget is settled: 59.63 GB of weights, 7.03 of pools, about 1.2 for CUDA graph capture, and 10.34 GB still free. Across all 32 cards nvidia-smi showed 70,960 MiB in use of 81,559 MiB, and the server advertised the full context_len=1048576.

Per-GPU memory budget on a single NVIDIA H100

Where each of the 32 cards spends its 78.23 GB, measured at the moment the server reported ready.

 

Read from the SGLang memory log lines quoted above. The 10.34 GB left over is what pays for longer prompts and more concurrent requests, and it is what --mem-fraction-static moves.

💡

The free-memory line is the one to watch. available_gpu_mem at startup tells you how much room the deployment has left before anything else is added. Ten gigabytes per card is comfortable for this configuration, and it is the budget that CUDA graphs, a larger KV cache or speculative decoding would come out of.

How to Deploy Kimi K3 on Hyperstack

Now, let us walk through the step-by-step process of standing up the four-node cluster and serving Kimi K3 across all 32 GPUs.

📘

If you would rather orchestrate this with Kubernetes, we have deployed comparable multi-node MoE models with managed Kubernetes and LeaderWorkerSet: Quick Start Guide: Deploying LLMs with Kubernetes on Hyperstack. For the parallelism theory behind the topology above, see How to Run Distributed Inference with vLLM.

Step 1: Accessing Hyperstack

First, you will need an account on Hyperstack.

  • Go to the Hyperstack website and log in.
  • If you are new, create an account and set up your billing information. Our documentation can guide you through the initial setup.

Step 2: Deploying Four 8x NVIDIA H100 Virtual Machines

From the Hyperstack dashboard, launch four identical GPU virtual machines. The node count is fixed by the tensor-parallel arithmetic above: tensor parallel 32 needs 32 GPUs, and nothing smaller divides the model.

  • Initiate Deployment: Click the "Deploy New Virtual Machine" button on the dashboard.

step 1

step 2

  • Choose the Operating System: Select the "Ubuntu Server 24.04 LTS R570 CUDA 12.8 with Docker" image. The driver line has to match what the engine container expects, and this image also ships Docker and the NVIDIA container runtime ready to use.
  • Environment: Choose an environment carrying NVIDIA H100 NVLink stock. Our run used CANADA-1. All four nodes must land in the same environment, because that is what puts them on a shared private network, and the entire deployment depends on it.
  • Select a Keypair: Choose an existing SSH keypair. Use the same keypair for all four nodes.
  • Network Configuration: Assign a Public IP to every node so you can reach them over SSH, and note each node's private IP once it is running.
  • Review and Deploy: Check the settings, click "Deploy", and repeat until all four nodes exist with identical settings.

step 3

💡

Spot capacity suits this workload well, with one condition. Spot VMs run on surplus capacity at a lower rate and can be reclaimed when that capacity is needed, so treat the cluster as disposable and capture any output you care about as you go. For an evaluation run like this one it turns a $80/hr cluster into a $64/hr cluster.

Step 3: Configure the Firewall

The four nodes have to talk to each other freely on the private network, and you need to reach the API from your own machine. Add these firewall rules:

Port Source Why
22 your public IP /32 SSH access to all four nodes
8000 your public IP /32 The inference API, on the head node only
1 to 65535 TCP the other nodes' private IPs NCCL, Gloo and the torch.distributed rendezvous on port 20000

Every node's private IP is shown on its detail page in the dashboard, so you can fill in that third rule without logging in anywhere. It is also the rule people forget. NCCL negotiates its ports dynamically, so pinning it to a single port does not work and the range has to be wide. The source does not have to be wide, though. Scope it to the peer nodes' private addresses, or at most to your environment's own subnet, rather than to the whole 10.0.0.0/8 range: a cloud private network is shared, and "not reachable from the internet" is not the same as "reachable only by my four machines".

⚠️

The inference endpoint has no authentication. That is why every example here passes EMPTY as the key. Anything that reaches port 8000 has full use of a 2.8 trillion parameter model, so keep the rule scoped to your own IP and never open it to 0.0.0.0/0.

Step 4: Accessing Your Nodes

Once all four virtual machines are running, copy their Public IP addresses from the dashboard and connect to each in a separate terminal.

Terminal, on your own machineSHELL
# Connect to each node using your private key and the node's public IP
ssh -i [path_to_your_ssh_key] ubuntu@[your_node_public_ip]

You will also need three pieces of information on every node: its private IP, the name of its private network interface, and confirmation that the large disk is mounted. Run this on all four:

Terminal, run on all four nodesSHELL
# Private IP and the name of the private interface
ip -o -4 addr show | awk '{print $2, $4}' | grep -E ' 10\.'

# All eight NVIDIA GPUs present, with their real memory size
nvidia-smi --query-gpu=index,name,memory.total --format=csv

# The two disks: a small root and a very large NVMe
df -h / /ephemeral
Output of the three commands aboveOUTPUT
ens6 10.0.0.219/24

index, name, memory.total [MiB]
0, NVIDIA H100 PCIe, 81559 MiB
1, NVIDIA H100 PCIe, 81559 MiB
...
7, NVIDIA H100 PCIe, 81559 MiB

Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1        96G   12G   84G  13% /
/dev/vdb1       6.3T   28K  6.0T   1% /ephemeral

On our NVIDIA H100 nodes the private interface was ens6 on the 10.0.0.0/24 subnet, and the head node's private IP was 10.0.0.219. Do not assume these values match yours. The interface name in particular varies by flavour, so read both from the commands above and substitute them wherever this guide shows ens6 or a 10.0.0.x address. Both values go into the launch command in Step 8, so it is worth writing them down now.

The disk check is what shapes the rest of the guide. The machine has two disks and only one of them matters here: the root filesystem is roughly 96 GB, while the multi-terabyte NVMe disk is mounted separately at /ephemeral and offers around 6.3 TB on this flavour. It is documented under ephemeral storage. Kimi K3 is 1.56 TB and the container image adds another 13.3 GB on top, so nothing about this deployment fits on the root disk.

Step 5: Point Docker and the Model Cache at /ephemeral

Two things have to move onto the big disk: the Hugging Face cache that will hold the weights, and Docker's own storage, because container images are pulled onto the root disk by default. Everything in this step runs on all four nodes.

Start with the directories. /ephemeral/hf becomes the Hugging Face cache, and it is the path that gets mounted into the container later so the engine finds the weights already present:

Terminal, all four nodes: 1 of 3, the cache directoriesSHELL
# The Hugging Face cache and Docker's storage both go on the big NVMe disk
sudo mkdir -p /ephemeral/hf /ephemeral/docker
sudo chown -R ubuntu:ubuntu /ephemeral/hf

Next, move Docker itself. The data-root key tells the daemon to store images and layers on the NVMe disk rather than the 96 GB root partition, and it only takes effect on a restart:

Terminal, all four nodes: 2 of 3, move DockerSHELL
# Docker data-root -> /ephemeral (root is only ~96 GB; the image alone is 13.3 GB)
sudo systemctl stop docker docker.socket
echo '{"data-root":"/ephemeral/docker"}' | sudo tee /etc/docker/daemon.json
sudo systemctl start docker
sleep 4

Finally, clear anything Docker has already written to the root disk, then check both halves landed. On a fresh node the prune finds little to remove, and it earns its place when you are re-running on a node that has pulled images before. docker info should now report Docker Root Dir: /ephemeral/docker:

Terminal, all four nodes: 3 of 3, reclaim and confirmSHELL
# Reclaim whatever the previous storage driver left on the root disk
docker system prune -af

# Then confirm both halves of the move
docker info | grep 'Docker Root'
df -h /
💡

Prune first, then pull. Recent Docker releases also keep image layers under the containerd snapshotter at /var/lib/containerd, which data-root does not cover. Running the prune before the pull, and keeping an eye on df -h / while it runs, gives the 13.3 GB image the whole NVMe disk to work with.

Step 6: Download Kimi K3 and Pull the Engine Image

Every node needs its own complete copy of the weights, because every rank loads its shard of every tensor from local disk. The image pull and the model download are independent, so start them together on all four nodes. First make sure the Hugging Face client is present:

Terminal, run on all four nodesSHELL
# Ubuntu 24.04 marks its Python as externally managed, hence the flag
python3 -m pip install -q --break-system-packages huggingface_hub hf_transfer

Kick off the image pull first, because it is the quicker of the two and nothing about it depends on the weights:

Terminal, all four nodes: 1 of 3, the container imageSHELL
# Start the image pull in the background. It is 13.3 GB and it does not
# depend on the weights, so there is no reason to wait for it.
nohup docker pull lmsysorg/sglang:kimi-k3 >/tmp/pull.log 2>&1 &

Then start the weights. Three details in this command are doing the work: HF_TOKEN authenticates the download, because Hugging Face applies tighter rate limits to anonymous traffic than to signed-in users, so a token is what sustains full speed across 1.56 TB; HF_HOME puts the cache on the NVMe disk rather than the root partition, which is the whole point of the previous step; and max_workers=16 is what fills the pipe.

Terminal, all four nodes: 2 of 3, the weightsSHELL
# Authenticate, then pull 1.56 TB of weights onto the NVMe disk.
# max_workers=16 is what fills a multi-gigabit pipe on this flavour.
export HF_TOKEN="YOUR_HF_TOKEN_HERE"
nohup env HF_HOME=/ephemeral/hf HF_HUB_ENABLE_HF_TRANSFER=1 HF_TOKEN="$HF_TOKEN" \
  python3 -c "from huggingface_hub import snapshot_download; snapshot_download('moonshotai/Kimi-K3', max_workers=16)" \
  >/tmp/dl.log 2>&1 &

Both are detached, so you can watch the disk fill rather than the terminal. On our nodes the transfer peaked at 1.8 GB/s and finished in about 32 minutes, an average of roughly 0.8 GB/s across the whole checkpoint:

Terminal, all four nodes: 3 of 3, watch it landSHELL
# Watch the disk fill. Expect roughly 1.5 TB over about half an hour.
watch -n 30 'df -h /ephemeral | tail -1'
💡

The download is the long pole, and it is parallel. All four nodes pull at once and do not wait for each other, so the cluster is ready roughly 32 minutes after the last node starts downloading, not 128. Environment variable behaviour is documented in the huggingface_hub reference.

Step 7: Verify All 96 Shards Before You Serve

A 32-rank cluster spends about eleven minutes loading before it is in a position to notice that one of the 96 shards is short. Confirming the download first takes seconds on an idle node and gives you a definite answer before the expensive part begins, so it is worth the keystrokes on a checkpoint this size.

The script reads your token from ~/hf_token.txt, so save it there first with echo "$HF_TOKEN" > ~/hf_token.txt. It then builds in four parts, starting with the arguments and the header that authenticates the API call:

verify_k3_download.py · 1 of 4, arguments and authPYTHON
#!/usr/bin/env python3
# verify_k3_download.py - confirm a node has the COMPLETE Kimi K3 checkpoint
# before we try to serve. Run ON the node.
import argparse, glob, json, os, urllib.request

ap = argparse.ArgumentParser()
ap.add_argument("--repo", default="moonshotai/Kimi-K3")
ap.add_argument("--hf-home", default="/ephemeral/hf")
ap.add_argument("--token-file", default=os.path.expanduser("~/hf_token.txt"))
a = ap.parse_args()

tok = ""
if os.path.exists(a.token_file):
    tok = open(a.token_file).read().strip()
h = {"User-Agent": "Mozilla/5.0"}
if tok:
    h["Authorization"] = "Bearer " + tok

Next it asks Hugging Face what should be there. The repository metadata lists every file in the repo, and filtering it to .safetensors gives the 96 names to look for:

verify_k3_download.py · 2 of 4, what should be herePYTHON
# expected manifest from HF
req = urllib.request.Request(f"https://huggingface.co/api/models/{a.repo}", headers=h)
meta = json.loads(urllib.request.urlopen(req, timeout=60).read())

expected = [s["rfilename"] for s in (meta.get("siblings") or [])]
exp_st = sorted(f for f in expected if f.endswith(".safetensors"))

Then it finds the local copy. The downloader writes into a snapshot directory named after the commit hash and fills it with symlinks into the blob store, so the size of each link has to be read through os.path.realpath rather than from the link itself:

verify_k3_download.py · 3 of 4, what is actually herePYTHON
snap = os.path.join(a.hf_home, "hub", "models--" + a.repo.replace("/", "--"), "snapshots")
dirs = glob.glob(os.path.join(snap, "*"))
if not dirs:
    print(f"MISSING: no snapshot dir under {snap}")
    raise SystemExit(2)
d = dirs[0]

local, total = {}, 0
for f in exp_st:
    p = os.path.join(d, f)
    if os.path.exists(p):
        sz = os.path.getsize(os.path.realpath(p))
        local[f] = sz
        total += sz

Finally it compares the two and exits non-zero if anything is missing, so the whole thing can go in front of your launch command:

verify_k3_download.py · 4 of 4, compare and reportPYTHON
missing = [f for f in exp_st if f not in local]
incomplete = glob.glob(os.path.join(d, "*.incomplete"))
cfg_ok = os.path.exists(os.path.join(d, "config.json"))

print(f"repo             : {a.repo}")
print(f"snapshot dir     : {d}")
print(f"safetensors      : {len(local)}/{len(exp_st)} present")
print(f"total bytes      : {total:,} ({total/1e9:.2f} GB)")
print(f"config.json      : {'OK' if cfg_ok else 'MISSING'}")
print(f".incomplete files: {len(incomplete)}")

ok = (not missing) and (not incomplete) and cfg_ok
print("RESULT:", "COMPLETE" if ok else "INCOMPLETE")
raise SystemExit(0 if ok else 1)

Run it on every node. What you want to see, with the same snapshot hash on all four:

Output of verify_k3_download.py, on every nodeOUTPUT
repo             : moonshotai/Kimi-K3
snapshot dir     : /ephemeral/hf/hub/models--moonshotai--Kimi-K3/snapshots/9f62e4e9fffbd0a83ddd60e1c209d828994b3569
safetensors      : 96/96 present
total bytes      : 1,560,936,091,448 (1560.94 GB)
config.json      : OK
.incomplete files: 0
RESULT: COMPLETE

Step 8: Launch Kimi K3 Across All Four Nodes

Unlike a pipeline-parallel deployment, there is no separate follower command here. All four nodes run the same command, and only two values change between them: --node-rank and SGLANG_HOST_IP. Rank 0 is the head, and it is the only node that will answer HTTP, but it holds no more of the model than the others.

Set the five values first. Only the first two differ between machines, so the safest way to work is to open four terminals, paste this block into each, and change the two lines:

Terminal, all four nodes: 1 of 3, the five valuesSHELL
# Run this on EVERY node. Only RANK and SELF_PRIV change between them.
export RANK=0                     # 0 on the head, then 1, 2 and 3
export SELF_PRIV="10.0.0.219"     # THIS node's private IP
export HEAD_PRIV="10.0.0.219"     # the head node's private IP, on all four
export NIC="ens6"                 # the private interface from Step 4
export HF_TOKEN="YOUR_HF_TOKEN_HERE"

# Clear any container left from an earlier launch
docker rm -f sgl 2>/dev/null || true

Next collect the engine arguments. Putting them in a variable keeps them separate from the container plumbing, and it makes the difference between the four nodes a single line rather than a diff across thirty:

Terminal, all four nodes: 2 of 3, the engine argumentsSHELL
# Everything the engine itself needs, collected in one variable so the
# docker command below stays readable.
K3_ARGS="--trust-remote-code \
  --model-path moonshotai/Kimi-K3 \
  --tp-size 32 \
  --ep-size 32 \
  --nnodes 4 \
  --node-rank $RANK \
  --dist-init-addr $HEAD_PRIV:20000 \
  --moe-runner-backend marlin \
  --decode-attention-backend flashmla \
  --mem-fraction-static 0.85 \
  --dist-timeout 3600 \
  --reasoning-parser kimi_k3 \
  --tool-call-parser kimi_k3 \
  --host 0.0.0.0 \
  --port 8000"

Then launch. Everything here is container-level: the GPUs, host networking and shared memory, the weights mounted where the engine looks for them, and the environment the engine reads at start-up:

Terminal, all four nodes: 3 of 3, launchSHELL
docker run -d --name sgl --gpus all --network host --ipc=host --shm-size 32g \
  --ulimit memlock=-1 --ulimit stack=67108864 \
  -v /ephemeral/hf:/root/.cache/huggingface \
  --env "HF_TOKEN=$HF_TOKEN" \
  --env NCCL_CUMEM_ENABLE=1 \
  --env PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
  --env SGLANG_ENABLE_TP_MEMORY_INBALANCE_CHECK=0 \
  --env SGLANG_K3_ATTN_RES_MODE=jit \
  --env SGLANG_MOE_FUSED_GATE_RADIX=1 \
  --env SGLANG_HOST_IP=$SELF_PRIV \
  --env NCCL_SOCKET_IFNAME=$NIC \
  --env GLOO_SOCKET_IFNAME=$NIC \
  --env NCCL_IB_DISABLE=1 \
  lmsysorg/sglang:kimi-k3 \
  sglang serve $K3_ARGS

Start rank 0 first, then the other three within a minute or so of each other. The rendezvous on port 20000 waits for all four, and --dist-timeout 3600 gives it an hour before it gives up, which is deliberate on a load this size.

Here is what each part of those three blocks is for:

Flag or variable What it does
--tp-size 32 Shards every layer across all 32 GPUs. The maximum this model permits.
--ep-size 32 Spreads the 896 experts across the same 32 ranks, one slice each.
--nnodes 4 --node-rank Cluster size, and this node's position in it. The only value that differs per node.
--dist-init-addr The rendezvous point. All four nodes point at the head on port 20000.
--moe-runner-backend marlin The W4A16 kernel that runs MXFP4 weights on Hopper.
--decode-attention-backend flashmla The Hopper-tuned latent attention decode kernel. Prefill stays on FlashAttention 3.
--mem-fraction-static 0.85 Size of the static memory pool. The lever if you need more or less KV cache.
--dist-timeout 3600 Rendezvous patience. Loading 1.56 TB across four nodes is not quick.
--reasoning-parser kimi_k3 Splits the chain of thought into a separate reasoning_content field.
--tool-call-parser kimi_k3 Turns K3's tool tokens into standard OpenAI tool_calls.
--trust-remote-code K3 ships its own tokeniser and processor classes in the repository.
PYTORCH_CUDA_ALLOC_CONF expandable_segments:True. Keeps the resident footprint close to the size of the weights.
SGLANG_ENABLE_TP_MEMORY_INBALANCE_CHECK Set to 0. Skips the balance assertion across ranks during load.
SGLANG_K3_ATTN_RES_MODE=jit Compiles the Attention Residuals path just in time.
SGLANG_MOE_FUSED_GATE_RADIX=1 Radix setting for the fused expert gate.
SGLANG_HOST_IP The address this rank advertises to the other 31. Differs per node.
NCCL_SOCKET_IFNAME / GLOO_SOCKET_IFNAME Pins collectives to the private interface, the one the rendezvous uses.
NCCL_IB_DISABLE=1 Use TCP sockets. On-demand nodes have no InfiniBand.
--ulimit memlock=-1 Unlimited pinned memory, the standard NVIDIA requirement for multi-node NCCL.
--network host --ipc=host Host networking and shared memory for the eight worker processes per node.
💡

Keep the container permissions exactly as they are above. The --ulimit pair, --ipc=host and --network host grant everything multi-node NCCL needs here, so there is no reason to add --privileged. If you relaunch, run docker rm -f sgl on every node first and check with nvidia-smi --query-compute-apps=pid --format=csv that the GPUs are free.

Step 9: Verify the Deployment

Follow the head node while the cluster forms:

Terminal, on the head nodeSHELL
docker logs -f sgl

The timeline below is our run, from the first container log line to the readiness message. Four moments in it are the ones to watch for, and they carry a filled marker.

Eleven minutes from container start to live endpoint

Timestamps are taken verbatim from the head node log of the run described in this guide.

 
 
07:39:14Container starts
Tokeniser loads, 163,840 words, and all 32 ranks begin the rendezvous.
 
 
07:39:58Process group formed
Init torch distributed ends, elapsed 41.49 s, 0.47 GB used. Every rank has found the other 31.
 
 
07:39:58Weight loading begins
78.23 GB available per GPU. The Marlin MoE path is selected and logged.
 
 
07:45:16Weights resident
317.50 s to load. 59.63 GB used per GPU, 18.60 GB left.
 
 
07:45:18Memory pools allocated
KV cache 3.62 GB at 140,352 tokens, plus the KDA recurrent state pool. 11.57 GB left.
 
 
07:45:18Attention backends chosen
Hybrid: flashmla for decode, FlashAttention 3 for prefill.
 
 
07:49:32Application startup complete
context_len=1048576, max_running_requests=49, 10.34 GB free per GPU.
 
07:50:15Ready to roll
Eleven minutes and one second after the container started.

Weight loading is 48 per cent of the wall clock, and the four minutes between the memory pools and startup are CUDA graph capture across 32 ranks.

Now confirm the model is being served, from your own machine:

Terminal, on your own machineSHELL
# Use the HEAD node's PUBLIC IP here, not the private one
export HEAD_IP="your.head.node.public.ip"

curl -s http://$HEAD_IP:8000/v1/models
Response from GET /v1/modelsJSON
{
  "object": "list",
  "data": [
    {
      "id": "moonshotai/Kimi-K3",
      "object": "model",
      "owned_by": "sglang",
      "root": "moonshotai/Kimi-K3",
      "max_model_len": 1048576
    }
  ]
}

The max_model_len of 1,048,576 is the line to look for. A 2.8 trillion parameter model is live on 32 GPUs with its full one million token context window intact, not a truncated one.

Finally, look at the memory across all four nodes. This is 2.8 trillion parameters resident on thirty-two NVIDIA H100 GPUs:

nvidia-smi across all four nodesOUTPUT
=== node 0 (head, rank 0) ===
0, NVIDIA H100 PCIe, 71790 MiB, 81559 MiB
1, NVIDIA H100 PCIe, 70960 MiB, 81559 MiB
...
=== node 1 (rank 1) ===
0, NVIDIA H100 PCIe, 70960 MiB, 81559 MiB
...
=== node 2 (rank 2) ===
0, NVIDIA H100 PCIe, 70960 MiB, 81559 MiB
...
=== node 3 (rank 3) ===
0, NVIDIA H100 PCIe, 70960 MiB, 81559 MiB
...

Thirty-one of the thirty-two cards read 70,960 MiB of 81,559 MiB. One card reads 71,790 MiB, about 830 MiB more, which is consistent with the rank that also carries the HTTP server process. That leaves about 10.3 GB of headroom on every card, matching the engine's own available_gpu_mem=10.34 GB exactly. Kimi K3 is live.

Talking to Kimi K3

The endpoint is OpenAI compatible, so every example below is a standard chat completion. What is specific to Kimi K3 is not the transport but the behaviour: it always thinks, it returns that thinking in a separate field, and it expects you to hand that thinking back on the next turn.

The first request, and the field most clients ignore

Terminal, on your own machineSHELL
curl -s http://$HEAD_IP:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer EMPTY" \
  -d '{
    "model": "moonshotai/Kimi-K3",
    "messages": [{"role": "user", "content": "Who are you? One sentence."}],
    "max_tokens": 256
  }'

The reply is a standard chat completion with one extra field. Here is the message it returned:

Response, the messageJSON
"message": {
  "role": "assistant",
  "content": "Hello! I'm Kimi, an AI assistant developed by Moonshot AI.",
  "reasoning_content": "They specify background identity: current assistant is Kimi,
                        developed by Moonshot AI. Need answer naturally, short
                        sentence. Final only.",
  "tool_calls": null
}

The answer is in content as usual, but reasoning_content is populated alongside it. That field exists because the server was launched with --reasoning-parser kimi_k3, and without it the thinking would arrive inline in the answer. Now look at what it cost:

Response, the usage blockJSON
"usage": {
  "prompt_tokens": 137,
  "completion_tokens": 135,
  "total_tokens": 272,
  "reasoning_tokens": 109
}

reasoning_tokens reports 109 of the 135 completion tokens. On a question this small, 81 per cent of the generated tokens were thinking, and a client that reads only content pays for all of them while seeing none of them.

Every Python example from here on shares one small helper. It is the standard library only, so there is nothing to install, and it is the same request shape the shell examples above send:

Python, on your own machine: the shared helperPYTHON
import json
import time
import urllib.request

BASE = "http://<HEAD_NODE_PUBLIC_IP>:8000/v1"
MODEL = "moonshotai/Kimi-K3"
KEY = "EMPTY"

def post(body, timeout=1800):
    req = urllib.request.Request(
        BASE + "/chat/completions",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": "Bearer " + KEY})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.load(r), time.time() - t0

With that in place a chat request is a dictionary. The only field that is not standard OpenAI is reasoning_effort, and it sits at the top level of the body rather than inside chat_template_kwargs as some other models expect:

Python, on your own machine: 1 of 2, the requestPYTHON
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Explain what a mixture-of-experts model is, simply, in 2 sentences."},
]

resp, dt = post({"model": MODEL, "messages": messages, "max_tokens": 1024,
                 "temperature": 0.8, "top_p": 1.0,
                 "reasoning_effort": "high"})     # low, high or max

Reading the reply is where the difference shows. The two halves come back on separate keys, so you can log the thinking and show the answer:

Python, on your own machine: 2 of 2, reading the replyPYTHON
m = resp["choices"][0]["message"]
content = m.get("content") or ""
reasoning = m.get("reasoning_content") or ""

u = resp.get("usage", {})
ct = u.get("completion_tokens", 0)

print("--- thinking ---")
print(reasoning)              # the chain of thought lands here
print("--- answer ---")
print(content)                # the final answer lands here
print(f"    [{ct} tok in {dt:.1f}s = {ct / dt:.1f} tok/s]")
⚠️

There is no way to switch thinking off. Kimi K3 accepts low, high and max, and nothing else. The no_think value that some other models accept is discarded, with a line in the server log saying so. Budget your token accounting accordingly.

Head node log, after a no_think requestOUTPUT
[2026-07-28 07:57:13] Kimi K3 supports thinking_effort low/high/max;
                      ignoring reasoning_effort='no_think'.

Thinking effort: what the knob actually changes

The interesting question is not whether the effort setting works, but what it moves. We put the same word problem through at low and at high and compared the token accounting:

Python, the same prompt at two effort settingsPYTHON
prompt = ("Three friends split a bill. Ana pays twice what Ben pays, and Ben pays 3 more "
          "than Cara. The total is 43. How much does each pay?")

for effort in ("low", "high"):
    resp, dt = post({"model": MODEL, "max_tokens": 2000, "reasoning_effort": effort,
                     "messages": [{"role": "user", "content": prompt}]})
    u = resp.get("usage", {})
    print(f"  [{effort:>4}] reasoning_tokens={u.get('reasoning_tokens')} "
          f"completion={u.get('completion_tokens')} {dt:.1f}s")

The token counts move sharply, and the wall clock with them:

Token accounting for both runsOUTPUT
  [ low] reasoning_tokens=54 completion=202 33.6s
  [high] reasoning_tokens=184 completion=344 56.6s

The thinking itself is where the difference is visible. At low it is one line. At high the model restates the problem, solves it, checks the total and then considers whether non-integer amounts are acceptable:

reasoning_content from both runsOUTPUT
# the whole of the low-effort thinking:
Cara = c, Ben = c+3, Ana = 2c+6. Total 4c+9 = 43, c=8.5. Ben 11.5, Ana 23. Total 43

# the opening of the high-effort thinking:
The user wants me to solve a word problem. Let me work through it.
Let Cara pay x. Ben pays 3 more than Cara: Ben = x + 3.
Ana pays twice what Ben pays: Ana = 2(x + 3) = 2x + 6.
Total: x + (x + 3) + (2x + 6) = 43 ...
Non-integer amounts, but that's fine (money can be .50).

# both answers identical: Cara 8.50, Ben 11.50, Ana 23.00

Thinking effort scales the thinking, not the answer

The same three-variable word problem at low and at high effort. Both answers were correct and both were roughly the same length.

 

Reasoning tokens rose 3.4x from 54 to 184, while the visible answer grew by only eight per cent, from 148 to 160 tokens. Latency went from 33.6 s to 56.6 s.

That is a clean result for capacity planning. The effort setting is a latency and cost dial, not a verbosity dial. At low the model wrote a single compressed line of working. At high it restated the problem, solved it, checked the total and then explicitly considered whether non-integer amounts were acceptable. On a problem this size the extra thinking bought nothing, which is exactly the sort of thing worth measuring on your own workload before you default the whole fleet to max.

Preserved thinking history, which is a requirement rather than an option

Kimi K3 was trained in preserved thinking history mode. The model card is direct about what happens otherwise: if the harness fails to pass back the historical thinking content, or if a session running on another model is switched over to K3 mid-conversation, generation quality may become highly unstable. In practice that means the assistant message you append to messages has to carry reasoning_content as well as content and tool_calls.

Here is a probe that can only be answered correctly if the previous turn's thinking survived the round trip. Turn one is an ordinary request:

Python, preserved thinking: 1 of 3, turn onePYTHON
msgs = [{"role": "user", "content": "Tell me three random numbers."}]

resp1, dt1 = post({"model": MODEL, "messages": msgs, "max_tokens": 1024,
                   "temperature": 0.8, "top_p": 1.0, "reasoning_effort": "high"})
m1 = resp1["choices"][0]["message"]

This next block is the one that matters. The assistant message goes back onto messages with its reasoning attached, rather than being rebuilt from content alone:

Python, preserved thinking: 2 of 3, echo the turn backPYTHON
# pass the assistant message back AS-IS, including reasoning_content
assistant_turn = {"role": "assistant", "content": m1.get("content") or ""}
if m1.get("reasoning_content"):
    assistant_turn["reasoning_content"] = m1["reasoning_content"]
msgs.append(assistant_turn)

Turn two then asks about something that only ever existed inside that thinking:

Python, preserved thinking: 3 of 3, turn twoPYTHON
msgs.append({"role": "user",
             "content": "What other numbers did you have in mind while thinking? Name them."})

resp2, dt2 = post({"model": MODEL, "messages": msgs, "max_tokens": 1024,
                   "temperature": 0.8, "top_p": 1.0, "reasoning_effort": "high"})
m2 = resp2["choices"][0]["message"]

print(f"    turn1: {(m1.get('content') or '')[:90]}")
print(f"    turn2: {(m2.get('content') or '')[:120]}")
What the model returned on each turnOUTPUT
    turn1: 17, 482, 96
    turn2: I didn't have specific alternates in mind, I just picked three.
           Three more random ones could be: 8, 157, 642.

The model read its own prior reasoning, correctly reported that it had not held back any alternatives, and offered three new numbers instead of inventing a memory. That is the behaviour you want, and it is the behaviour you lose if the assistant turn is rebuilt from content alone.

💡

Most OpenAI-compatible client libraries drop unknown fields. If you rebuild the assistant turn from content and tool_calls alone, the reasoning is silently gone. Echo the message back whole, or add reasoning_content back explicitly as the examples here do.

Agentic tool calling with the kimi_k3 parser

Because the server was launched with --tool-call-parser kimi_k3, the model decides for itself when to reach for a tool and the parser converts its output into standard OpenAI tool_calls. The first of the two tools is a calculator, and it walks the expression as an abstract syntax tree rather than calling eval, so anything other than arithmetic raises a ValueError rather than reaching a shell:

Python, tool calling: 1 of 8, imports and the operator tablePYTHON
import ast
import operator

ops = {
    ast.Add:  operator.add,
    ast.Sub:  operator.sub,
    ast.Mult: operator.mul,
    ast.Div:  operator.truediv,
    ast.Pow:  operator.pow,
    ast.USub: operator.neg,
}

The calculator itself recurses over that tree. Anything that is not a number or one of the five permitted operators raises rather than evaluating:

Python, tool calling: 2 of 8, the calculatorPYTHON
def calculate(expression: str):
    def ev(n):
        if isinstance(n, ast.Constant):
            return n.value
        if isinstance(n, ast.BinOp):
            return ops[type(n.op)](ev(n.left), ev(n.right))
        if isinstance(n, ast.UnaryOp):
            return ops[type(n.op)](ev(n.operand))
        raise ValueError("bad expr")

    return {"result": ev(ast.parse(expression, mode="eval").body)}

The second tool is a stub, and a dispatch map ties the two names back to the two functions:

Python, tool calling: 3 of 8, the stub and the dispatch mapPYTHON
def get_weather(city: str):
    fake = {"Tokyo": "22C, clear", "London": "14C, rain", "Cairo": "35C, sunny"}
    return {"city": city, "weather": fake.get(city, "18C, partly cloudy")}

IMPL = {"calculate": calculate, "get_weather": get_weather}

Then describe the same two functions to the model. This is the standard OpenAI tools schema, with nothing Kimi-specific in it:

Python, tool calling: 4 of 8, the calculator schemaPYTHON
CALCULATE_TOOL = {
    "type": "function",
    "function": {
        "name": "calculate",
        "description": "Evaluate a basic arithmetic expression.",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {"type": "string", "description": "e.g. '47*89'"},
            },
            "required": ["expression"],
        },
    },
}

The second entry has the same shape, and the two go into the list the request will carry:

Python, tool calling: 5 of 8, the weather schemaPYTHON
WEATHER_TOOL = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}

TOOLS = [CALCULATE_TOOL, WEATHER_TOOL]

The loop itself is the usual pattern. Send the task, and stop as soon as a reply comes back without tool calls, because that reply is the answer:

Python, tool calling: 6 of 8, the loopPYTHON
task = ("What is 47 * 89? Also, what's the weather in Tokyo? "
        "Use your tools, then give me one combined answer.")
messages = [{"role": "system", "content": "You are a helpful assistant with tools."},
            {"role": "user", "content": task}]

for turn in range(6):
    resp, dt = post({"model": MODEL, "messages": messages, "tools": TOOLS,
                     "max_tokens": 1024, "temperature": 0.7, "reasoning_effort": "high"})
    m = resp["choices"][0]["message"]
    tcs = m.get("tool_calls") or []
    print(f"[turn {turn}] tool_calls={len(tcs)} "
          f"reasoning={len(m.get('reasoning_content') or '')} chars")

    if not tcs:
        print(m.get("content", ""))
        break

If there were tool calls, the assistant turn goes back onto messages first, and it carries the same three things every time:

Python, tool calling: 7 of 8, echo the turn backPYTHON
    # echo the assistant message back AS-IS (content + reasoning_content + tool_calls)
    at = {"role": "assistant", "content": m.get("content") or "", "tool_calls": tcs}
    if m.get("reasoning_content"):
        at["reasoning_content"] = m["reasoning_content"]
    messages.append(at)

Then each requested tool runs and its result is appended as a tool message, which is what the model reads on the next pass:

Python, tool calling: 8 of 8, run the toolsPYTHON
    for tc in tcs:
        fn = tc["function"]["name"]
        try:
            args = json.loads(tc["function"].get("arguments") or "{}")
            result = IMPL[fn](**args)
        except Exception as e:
            args, result = {}, {"error": str(e)}

        print(f"    -> {fn}({args}) = {result}")
        messages.append({"role": "tool", "tool_call_id": tc.get("id", fn),
                         "name": fn, "content": json.dumps(result)})

Kimi K3 requested both tools in a single turn rather than serialising them into two round trips. Its reasoning for that turn, 116 characters of it, reads: "The user wants 47*89 and the weather in Tokyo. I should call both tools in the same block since they are independent."

Output of the agent loopOUTPUT
[turn 0] tool_calls=2 reasoning=116 chars
    -> calculate({'expression': '47*89'}) = {'result': 4183}
    -> get_weather({'city': 'Tokyo'}) = {'city': 'Tokyo', 'weather': '22C, clear'}
[turn 1] tool_calls=0 reasoning=37 chars

Here are both results:

- 47 x 89 = 4,183
- Tokyo weather: 22C and clear

So, 47 times 89 equals 4,183, and it's currently a pleasant clear day at 22C in Tokyo!

The whole exchange took 36.25 seconds across two round trips. Parallel tool calls in one turn are the behaviour you want from an agentic model, and the kimi_k3 parser delivers it with no orchestration code of your own.

Native vision, tested against an image we generated ourselves

Kimi K3 is natively multimodal through MoonViT-V2, so images go into the same endpoint as text. Rather than describe a photograph and grade the answer by eye, we built the test image in the script: a 128 by 128 PNG whose left half is pure red and whose right half is pure blue, written byte by byte with nothing but the standard library. The correct answer is therefore known before the request is sent.

Python, vision: 1 of 2, build a known test imagePYTHON
import base64
import struct
import zlib

def make_png(w=128, h=128):
    rows = b""
    for _ in range(h):
        row = b"\x00"                                    # filter byte
        for x in range(w):
            row += b"\xff\x00\x00" if x < w // 2 else b"\x00\x00\xff"
        rows += row

    def chunk(tag, data):
        c = struct.pack(">I", len(data)) + tag + data
        return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)

    ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)    # 8-bit truecolour
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
            + chunk(b"IDAT", zlib.compress(rows, 9)) + chunk(b"IEND", b""))

Sending it is the same call as any other, with the image as a second content part rather than a separate endpoint:

Python, vision: 2 of 2, send itPYTHON
b64 = base64.b64encode(make_png()).decode()

resp, dt = post({"model": MODEL, "max_tokens": 400, "reasoning_effort": "low",
                 "messages": [{"role": "user", "content": [
                     {"type": "text", "text": "What do you see in this image? "
                                              "Name the colours and how they are arranged."},
                     {"type": "image_url",
                      "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]})

ans = (resp["choices"][0]["message"].get("content") or "").strip()
print(f"  latency {dt:.1f}s | usage={resp.get('usage')}")
print(f"  ANSWER: {ans[:300]}")
What Kimi K3 repliedOUTPUT
  latency 18.4s | usage={'prompt_tokens': 174, 'total_tokens': 272,
                          'completion_tokens': 98, 'reasoning_tokens': 54,
                          'prompt_tokens_details': {'cached_tokens': 0, 'image_tokens': 25}}
  ANSWER: The image shows two solid color blocks arranged side by side: the left half
          is red and the right half is blue, meeting along a sharp vertical line down
          the center.

That answer is objectively correct, including the vertical boundary down the centre. The usage block is the interesting part for anyone budgeting a vision workload: the whole image cost 25 image tokens, and the request completed in 18.4 seconds. Vision on Kimi K3 is not a bolt-on adapter, and it is priced in tokens like everything else.

Throughput on 32 NVIDIA H100 GPUs

Single-stream decode across our three chat prompts averaged 5.8 tokens per second, with individual prompts at 5.9, 5.8 and 5.6. The more useful question for a sparse MoE with room for 49 concurrent requests is what happens when requests arrive together, so we fired four at once. The script is standard library only, so it runs anywhere:

bench_concurrent.py · 1 of 3, imports and settingsPYTHON
import json
import threading
import time
import urllib.request

BASE = "http://<HEAD_NODE_PUBLIC_IP>:8000/v1"
MODEL = "moonshotai/Kimi-K3"
N = 4

One worker sends one request and records what it got back:

bench_concurrent.py · 2 of 3, one requestPYTHON
def one(i, res):
    body = {"model": MODEL, "max_tokens": 128, "temperature": 0.9,
            "messages": [{"role": "user",
                          "content": f"Write one interesting paragraph about topic #{i}: the deep ocean."}]}
    req = urllib.request.Request(
        BASE + "/chat/completions", data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"})

    t0 = time.time()
    with urllib.request.urlopen(req, timeout=600) as r:
        resp = json.load(r)

    res[i] = {"completion_tokens": resp["usage"]["completion_tokens"],
              "latency_s": time.time() - t0}

Then start all four at once and time the batch as a whole, which is the number that matters for a server rather than for a single user:

bench_concurrent.py · 3 of 3, fire four at oncePYTHON
res = {}
ths = [threading.Thread(target=one, args=(i, res)) for i in range(N)]

t0 = time.time()
for t in ths:
    t.start()
for t in ths:
    t.join()
wall = time.time() - t0

toks = sum(v["completion_tokens"] for v in res.values())
agg = toks / wall
print(json.dumps({"concurrency": N, "wall_s": round(wall, 2),
                  "total_completion_tokens": toks,
                  "aggregate_tok_per_s": round(agg, 1),
                  "per_stream_tok_per_s": round(agg / len(res), 1)}, indent=2))
Output of bench_concurrent.pyJSON
{
  "concurrency": 4,
  "wall_s": 36.55,
  "total_completion_tokens": 512,
  "aggregate_tok_per_s": 14.0,
  "per_stream_tok_per_s": 3.5
}

Single stream against four-way concurrency

Four parallel requests produced 2.4x the aggregate throughput of one, which is continuous batching amortising the routing and the cross-node collectives across concurrent work.

 

Measured end to end from the client, so prefill and queueing are included. The server log reports a raw decode rate of about 16.3 tokens per second at four running requests, which is the same result seen from the engine side.

What sets that figure, and what would move it

Those numbers come from a bring-up configuration, and every choice in it is a deliberate trade for a fast, cheap start rather than for peak throughput:

  • The MoE path is Marlin. Every expert matrix multiply dequantises MXFP4 into 16 bits on the way through, because the native MXFP4 backends target Blackwell.
  • The cards are PCIe rather than SXM. NVLink bridges pairs of cards, and the remaining hops inside each node go through the PCIe host bridge.
  • Collectives run over Ethernet. Twenty-four of the thirty-two ranks sit on a different machine from rank 0, so their all-reduces cross the private network.
  • Speculative decoding is off. Kimi K3 ships a draft path that the published recipes pair with the model, and we ran without it to keep the launch minimal.
  • Every request thinks. Kimi K3 has no no-think mode, so even a one-line answer pays for a reasoning pass. On our first request that was 109 of 135 tokens.

For scale at the other end of the range, the vLLM launch blog reports 111 tokens per second per user on TP 8 and 118 on TP 16 at batch size 1, rising to 331 and 370 with DSpark speculative decoding, measured on NVIDIA GB300 NVL72. That is a different hardware class, with native FP4 kernels and NVLink across the whole domain, and the distance between the two is a fair picture of what each of the trades above is worth.

So this is the shape of deployment to reach for when the job is evaluation, integration and correctness work: proving an agent harness against the model itself, measuring how your prompts behave at each thinking effort, testing vision inputs, and validating the tool-call plumbing end to end, on hardware you can book by the minute. When throughput becomes the requirement, the levers in order of effect are more memory per card, then native FP4, then speculative decoding.

📘

Kimi Delta Attention also changes prefix caching, which is where a long-context serving stack normally recovers its throughput. Moonshot AI has contributed a KDA prefix cache implementation to the vLLM community, and the official Kimi API reports a cache hit rate above 90 per cent in coding workloads on the back of it. As that lands in the open engines it will matter more to real workloads than any of the flags in this guide.

Cost and Tearing Down the Cluster

At $2.00 per GPU per hour on Spot, thirty-two NVIDIA H100 GPUs cost $64.00 per hour. Here is where that hour goes on a clean run that follows this guide:

Phase Wall clock Cluster cost
Provision four nodes and prepare the disks about 8 min $8.53
Download 1.56 TB on all four nodes in parallel about 32 min $34.13
Verify 96 shards on every node under 1 min $1.07
Launch and reach a live endpoint 11 min $11.73
Total to a working 32-GPU endpoint about 52 min $55.46

The download dominates, at about 62 per cent of the bill, and it is the one phase that does not get cheaper with faster GPUs. Everything after it is minutes. Add whatever time you intend to spend actually using the endpoint on top, at roughly $1.07 per minute.

When you are finished, stop the containers and delete the virtual machines. This is the step you must not skip:

Terminal, on each node then from anywhereSHELL
# On each node, stop the container first so the NVIDIA GPUs are released cleanly
docker rm -f sgl

# Then delete all four virtual machines from the Hyperstack dashboard,
# or through the API, and confirm they have gone.
curl -s -X DELETE https://infrahub-api.nexgencloud.com/v1/core/virtual-machines/$VM_ID \
  -H "api_key: $HYPERSTACK_API_KEY"
⚠️

Nothing on this cluster survives deletion. The ephemeral disk holding the 1.56 TB checkpoint is released with the virtual machine, and Spot VMs cannot be hibernated or saved as a custom image. Capture every result you care about before you delete, and treat the download as something you will repeat rather than something you will keep.

Why Deploy Kimi K3 on Hyperstack?

Hyperstack is a cloud platform engineered specifically to accelerate AI and machine learning workloads. Here is why it suits a model the size of Kimi K3:

Thirty-Two NVIDIA H100 GPUs, On Demand
Kimi K3 permits a maximum tensor-parallel size of 32, so four 8x NVIDIA H100-80G-PCIe-NVLink nodes is the largest valid shape on this generation. Hyperstack provides them on demand, with no reservation queue.
6.3 TB of NVMe on Every Node
A 1,560.94 GB checkpoint has to live somewhere fast, on all four nodes at once. The ephemeral NVMe disk at /ephemeral held the weights and the 13.3 GB container image with room to spare, at a peak of 1.8 GB/s while filling.
A Private Network That Carries Tensor Parallelism
Kimi K3 has no sanctioned pipeline-parallel strategy, so multi-node serving means tensor parallelism on the wire. Nodes in the same environment share a private network, and all 32 ranks joined one process group across it and stayed there for the whole session.
Matched CUDA and Docker Images
The Ubuntu 24.04 R570 CUDA 12.8 with Docker image ships the driver and the container runtime already matched to the engine image, so the deployment goes from ssh straight to docker run with no driver work at all.
Spot Capacity and Per-Minute Billing
Spot VMs put the 32-GPU cluster at $64.00 per hour, and billing runs while the machines exist and stops when you delete them. An evaluation run is measured in tens of dollars, not thousands.
A Route Past the 80 GB Ceiling
When the Marlin path stops being fast enough, NVIDIA H200 SXM at 141 GB per card and NVIDIA Blackwell reservations take the same launch command onto hardware with native FP4 kernels.

Serve an open 3T-class model

Run Kimi K3 across thirty-two NVIDIA H100 GPUs

Four nodes on Spot at $64.00 per hour. Our run downloaded 1.56 TB, launched 32 ranks and reached a live one-million-token endpoint eleven minutes after the containers started.

32x NVIDIA H100TP 32 x EP 321M contextReady in 11 minutes

Launch an NVIDIA GPU cluster on Hyperstack today.

FAQs

What hardware do you need to run Kimi K3?

The checkpoint is 1,560.94 GB across 96 shards and every expert has to stay resident, so the constraint is total GPU memory rather than compute. The official vLLM recipe asks for at least one 8x NVIDIA B300 node. This guide serves it on 32x NVIDIA H100 80GB instead, across four Hyperstack nodes.

What is the maximum tensor-parallel size for Kimi K3?

Thirty-two. Tensor parallelism has to divide both the 96 attention heads and the 7168 hidden size, and their greatest common divisor is 32. Adding a fifth node therefore cannot lower the weights each GPU carries.

Why does the Kimi K3 endpoint always return reasoning_content?

Kimi K3 always thinks: it accepts an effort of low, high or max, and nothing turns thinking off. Launching with --reasoning-parser kimi_k3 puts that thinking in its own field, and you have to pass it back on the next turn or later replies become unstable.

How fast is Kimi K3 on 32x NVIDIA H100?

We measured 5.8 tokens per second single stream and 14.0 aggregate at four-way concurrency, on the Hopper Marlin path without speculative decoding. For contrast, the vLLM launch blog reports 111 per user on TP 8 at batch size 1, measured on NVIDIA GB300 NVL72.

How much does it cost to run Kimi K3 on Hyperstack?

Four 8x NVIDIA H100 Spot nodes are $2.00 per GPU per hour, so $64.00 per hour for the 32-GPU cluster. A clean run reaches a live endpoint in about 52 minutes, or roughly $55. Delete the machines afterwards, because the ephemeral disk holding the weights goes with them.

Fareed Khan

Fareed Khan

calendar 28 Jul 2026

Read More