Skip to main content
MiniVLLM uses tensor parallelism to distribute a single model across multiple GPUs. Each GPU holds a shard of every weight matrix and cooperates with the other GPUs using NCCL collectives.

How tensor parallelism works

A standard linear layer computes Y = X W. With tensor parallelism the weight matrix W is split across GPUs and the computation is distributed:
W is split along dim=0 (output features). Each GPU computes a slice of the output independently — no communication is needed during the forward pass.
Used for: Q, K, V projections and MLP gate/up projections.
The two types are always paired: a column-parallel layer shards the output, which becomes the sharded input consumed by the following row-parallel layer. The only cross-GPU communication is the dist.all_reduce at the end of each row-parallel layer.

Linear layer variants

ColumnParallelLinear

Each GPU stores output_size // tp_size output rows. The weight loader extracts the shard corresponding to the current rank.

RowParallelLinear

Each GPU stores input_size // tp_size input columns. A dist.all_reduce sums partial results after the matrix multiply.

QKVColumnParallelLinear

For attention, the column split must respect head boundaries. Each GPU handles complete attention heads rather than fractional ones:
The weight loader accepts load_weight_id ('q', 'k', or 'v') to identify which sub-matrix is being loaded and computes the correct offset within the merged QKV tensor.

MergedColumnParallelLinear

Holds the gate and up MLP projections in a single weight tensor. The weight loader is called once per sub-matrix with a loaded_weight_id integer (0 for gate, 1 for up):

Data flow through an attention block


Enabling multi-GPU inference

Change world_size in the config dict passed to LLMEngine:
All of num_heads, num_kv_heads, and vocab_size must be divisible by world_size. The model layers assert this at construction time.

Process group initialization

Every process (rank 0 and workers) calls dist.init_process_group inside ModelRunner.__init__:
dist.init_process_group is a collective barrier — rank 0 blocks until every worker rank has called it. This guarantees that all NCCL channels are established before any collective operation (e.g. all_reduce, all_gather) is executed.

Worker process lifecycle

After initialization, the worker loop runs:

Shared memory communication

Rank 0 (the scheduler process) communicates with worker ranks through a POSIX shared memory segment named myvllm. This avoids serializing data through the OS socket used by NCCL.
1

Rank 0 writes a call

2

Worker reads the call

3

Worker executes the method

Both rank 0 and workers call self.run(...). Because their model weights are sharded identically, all collective operations (all_reduce, etc.) execute in lock-step across ranks.
The shared memory segment is created by rank 0 after a dist.barrier() to ensure all worker processes have completed their initialization before the segment is written.

Why rank 0 handles scheduling and sampling

Scheduling. The scheduler maintains the waiting and running queues and decides which sequences run each step. This is a CPU-side decision that does not need to be replicated across GPUs. Sampling. After the model forward pass, logits on rank 0 are either computed directly (single GPU) or gathered via dist.gather from all ranks. Only rank 0 calls SamplerLayer.forward to convert logits to token IDs:
Worker ranks return None from run. Only rank 0 returns the sampled token IDs to the engine, which then passes them to scheduler.postprocess.