{"slug": "pytorch-multi-process-inference-weight-sharing-via-inter-process-communication", "title": "PyTorch Multi-Process Inference Weight Sharing Via Inter-Process Communication", "summary": "PyTorch's multiprocessing module provides a CUDA Inter-Process Communication (IPC) API that enables sharing model weights across multiple processes for inference, avoiding duplication in GPU VRAM. The technique, demonstrated with PyTorch and AOTInductor, addresses the Global Interpreter Lock (GIL) bottleneck by running models in separate processes while sharing weights via CUDA IPC.", "body_md": "# PyTorch Multi-Process Inference Weight Sharing Via Inter-Process Communication\n\nIntroduction\n\nPyTorch is probably the most popular deep learning framework for model training. Ideally, without any additional effort, PyTorch model could be used for inference seamlessly in Python. However, in practice, there are some concerns when using PyTorch for inference in Python, primarily because of the Global Interpreter Lock (GIL) in Python. The GIL prevents multiple threads from executing Python bytecodes at once, which can lead to performance bottlenecks in high-throughput applications. To overcome this limitation, PyTorch models can be run in multiple processes, where each process runs its own Python interpreter and has its own GIL. However, because model weights cannot be natively shared across processes in Python, multi-process inference on single-GPU can lead to duplicated model weights in GPU VRAM, which can quickly exhaust the available GPU memory. To mitigate this issue, model weights can be shared across processes using CUDA Inter-Process Communication (IPC).\n\nIt turns out that PyTorch `multiprocessing`\n\nmodule has abstracted away the details of CUDA IPC, and provides a simple API to share CUDA tensors across processes. In this blog post, I will demonstrate how to use PyTorch `multiprocessing`\n\nmodule to share model weights across multiple processes for inference, and avoid weight duplication in GPU VRAM.\n\nPyTorch Multi-Process Inference Weight Sharing\n\nIn the following example, I will demonstrate how to share model weights via CUDA IPC across multiple processes for PyTorch and AOTInductor inference using PyTorch `multiprocessing`\n\nmodule.\n\n```\n123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322\n```\n\n | \n\n``` python\nimport argparseimport ioimport osimport tempfileimport timeimport torchimport torch.nn as nnimport torch.multiprocessing as mpimport torch._inductor# =====================================================================# 1. MODEL DEFINITION# =====================================================================class SharedWeightInferenceEngine(nn.Module):    def __init__(self,                 in_features: int = 16384,                 hidden_dim: int = 16384,                 out_features: int = 12800):        super().__init__()        self.encoder = nn.Linear(in_features, hidden_dim)        self.head = nn.Linear(hidden_dim, out_features)    def forward(self, x: torch.Tensor) -> torch.Tensor:        x = torch.relu(self.encoder(x))        return self.head(x)# =====================================================================# 2. HELPER FUNCTIONS# =====================================================================def get_device_vram_usage_mb(device_id: int = 0) -> float:    \"\"\"Return device-wide GPU memory usage in MB.\"\"\"    torch.cuda.set_device(device_id)    free_bytes, total_bytes = torch.cuda.mem_get_info(device_id)    return (total_bytes - free_bytes) / (1024**2)def load_aoti_runner(package_path: str, state_dict: dict, device_id: int):    \"\"\"Load an AOTI runner and bind it to the supplied model weights.\"\"\"    runner = torch._inductor.aoti_load_package(package_path,                                               device_index=device_id)    # To enable IPC weight sharing using the parent state dict,    # uncomment the following lines:    expected_keys = set(state_dict.keys())    if hasattr(runner, \"get_constant_fqns\"):        assert set(runner.get_constant_fqns()) == expected_keys    # Now the weight tensor pointers or references are pointing to the shared weights in the parent process.    runner.load_constants(state_dict,                          check_full_update=True,                          user_managed=False)    torch.cuda.synchronize()    time.sleep(0.2)    return runner# =====================================================================# 3. WORKER PROCESS INFERENCE LOOPS# =====================================================================def pytorch_inference_worker(rank: int, model, input_queue: mp.Queue,                             output_queue: mp.Queue):    pid = os.getpid()    torch.cuda.set_device(0)    model.eval()    encoder_ptr = model.encoder.weight.data_ptr()    head_ptr = model.head.weight.data_ptr()    print(f\"\\n[Worker {rank} | PID {pid}] Initialized successfully!\")    print(f\"  ├── Shared 'encoder.weight' CUDA Address : {hex(encoder_ptr)}\")    print(f\"  └── Shared 'head.weight' CUDA Address    : {hex(head_ptr)}\")    while True:        task = input_queue.get()        if task is None:            print(                f\"[Worker {rank} | PID {pid}] Received shutdown signal. Exiting.\"            )            break        task_id, batch_cpu = task        start_time = time.perf_counter()        with torch.no_grad():            batch_gpu = batch_cpu.to('cuda', non_blocking=True)            logits = model(batch_gpu)            preds = torch.argmax(logits, dim=-1)        elapsed_ms = (time.perf_counter() - start_time) * 1000        print(            f\"[Worker {rank} | PID {pid}] Processed Task #{task_id} in {elapsed_ms:.2f}ms\"        )        output_queue.put((task_id, preds.cpu(), rank))def aoti_inference_worker(rank: int, package_path: str,                          parent_state_dict: dict, input_queue: mp.Queue,                          output_queue: mp.Queue):    pid = os.getpid()    device_id = 0    torch.cuda.set_device(device_id)    optimized_runner = load_aoti_runner(package_path, parent_state_dict,                                        device_id)    encoder_ptr = parent_state_dict[\"encoder.weight\"].data_ptr()    head_ptr = parent_state_dict[\"head.weight\"].data_ptr()    print(        f\"\\n[Worker {rank} | PID {pid}] Loaded AOTInductor package from {os.path.basename(package_path)}!\"    )    print(        f\"  ├── Parent-process 'encoder.weight' CUDA Address : {hex(encoder_ptr)}\"    )    print(        f\"  └── Parent-process 'head.weight' CUDA Address    : {hex(head_ptr)}\"    )    while True:        task = input_queue.get()        if task is None:            print(                f\"[Worker {rank} | PID {pid}] Received shutdown signal. Exiting.\"            )            break        task_id, batch_cpu = task        start_time = time.perf_counter()        with torch.no_grad():            batch_gpu = batch_cpu.to('cuda', non_blocking=True)            logits = optimized_runner(batch_gpu)            preds = torch.argmax(logits, dim=-1)        elapsed_ms = (time.perf_counter() - start_time) * 1000        print(            f\"[Worker {rank} | PID {pid}] Processed Task #{task_id} in {elapsed_ms:.2f}ms\"        )        output_queue.put((task_id, preds.cpu(), rank))# =====================================================================# 4. MAIN ORCHESTRATOR# =====================================================================def parse_args(argv=None):    parser = argparse.ArgumentParser(        description=        \"Run a CUDA IPC demo using standard PyTorch or AOTInductor execution\"    )    parser.add_argument(\"--mode\",                        choices=[\"pytorch\", \"aoti\"],                        default=\"pytorch\")    parser.add_argument(\"--workers\", type=int, default=3)    parser.add_argument(\"--batches\", type=int, default=6)    parser.add_argument(\"--in-dim\",                        type=int,                        default=16384,                        help=\"Input feature size\")    parser.add_argument(\"--hidden-dim\",                        type=int,                        default=16384,                        help=\"Encoder output / hidden layer size\")    parser.add_argument(\"--out-dim\",                        type=int,                        default=12800,                        help=\"Output classification size\")    return parser.parse_args(argv)def main(argv=None):    args = parse_args(argv)    mp.set_start_method('spawn', force=True)    if not torch.cuda.is_available():        raise RuntimeError(\"CUDA is required for this demonstration.\")    main_pid = os.getpid()    print(\"=\" * 70)    print(        f\"MAIN PROCESS [PID {main_pid}] STARTING {args.mode.upper()} CUDA IPC DEMO\"    )    print(\"=\" * 70)    print(        f\"\\n[Step A] Loading model ({args.in_dim} -> {args.hidden_dim} -> {args.out_dim}) onto GPU VRAM...\"    )    vram_before = torch.cuda.memory_allocated() / (1024**2)    model = SharedWeightInferenceEngine(        in_features=args.in_dim,        hidden_dim=args.hidden_dim,        out_features=args.out_dim).cuda().eval()    model.share_memory()    vram_after = torch.cuda.memory_allocated() / (1024**2)    model_vram = vram_after - vram_before    main_encoder_ptr = model.encoder.weight.data_ptr()    main_head_ptr = model.head.weight.data_ptr()    print(f\"  ├── Model VRAM Allocation : {model_vram:.2f} MB\")    print(f\"  ├── Main 'encoder' Pointer: {hex(main_encoder_ptr)}\")    print(f\"  └── Main 'head' Pointer   : {hex(main_head_ptr)}\")    if args.mode == \"pytorch\":        payloads = [model] * args.workers        worker_target = pytorch_inference_worker        label = \"Standard PyTorch model\"    else:        print(            f\"\\n[Step A.1] Exporting model and writing {args.workers} separate AOTI packages...\"        )        example_input = torch.randn(16, args.in_dim, device='cuda')        exported_program = torch.export.export(model, (example_input, ))        temp_dir = tempfile.mkdtemp()        # Build initial template package        template_package = os.path.join(temp_dir, \"model_aoti_base.pt2\")        template_package = torch._inductor.aoti_compile_and_package(            exported_program,            package_path=template_package,            inductor_configs={                \"always_keep_tensor_constants\": True,                # If it sets to True, the so file will contain model constant weights, which could be large.                # In our example, because we will load an AOTI package for each process,                # If it sets to True, it is possible that the GPU already runs out of memory before we set the weights to shared weights.                # If it sets to False, and later we did not load the weights to the AOTI package, there will be undefined behaviors.                \"aot_inductor.package_constants_in_so\": False,                # Save a copy of the model constant weights to disk in pickle format.                # It is useless for this example, because the weights were loaded from the PyTorch model state dict.                \"aot_inductor.package_constants_on_disk_format\":                \"pickle_weights\",            })        # Read template binary bytes        with open(template_package, \"rb\") as f:            package_bytes = f.read()        # Save unique file copies for each worker to break OS mmap page sharing        # If every process loads the same package file, the OS will share the same mmap pages for all processes.        # Consequently, the model weights will be automatically shared across processes,        # even without explicitly sharing the weights via the parent state dict.        payloads = []        for rank in range(args.workers):            worker_pkg_path = os.path.join(temp_dir,                                           f\"model_aoti_worker_{rank}.pt2\")            with open(worker_pkg_path, \"wb\") as f:                f.write(package_bytes)            payloads.append(worker_pkg_path)        worker_target = aoti_inference_worker        label = \"AOTInductor packages (unique copies per worker)\"    print(f\"\\n[Step B] Creating Inter-Process Communication queues...\")    input_queue = mp.Queue()    output_queue = mp.Queue()    print(        f\"\\n[Step C] Spawning {args.workers} worker processes for {label}...\")    workers = []    for rank in range(args.workers):        payload = payloads[rank]        if args.mode == \"aoti\":            parent_state_dict = model.state_dict()            process_args = (rank, payload, parent_state_dict, input_queue,                            output_queue)        else:            process_args = (rank, payload, input_queue, output_queue)        p = mp.Process(target=worker_target, args=process_args)        p.start()        workers.append(p)    time.sleep(1.0)    main_process_vram = torch.cuda.memory_allocated() / (1024**2)    device_vram_used = get_device_vram_usage_mb()    print(\"\\n\" + \"-\" * 70)    print(f\"VRAM VERIFICATION AFTER SPAWNING {args.workers} WORKERS:\")    print(        f\"  ├── Expected VRAM if duplicated ({args.workers + 1} copies) : ~{model_vram * (args.workers + 1):.2f} MB\"    )    print(        f\"  ├── Main-process allocated VRAM                             : ~{main_process_vram:.2f} MB\"    )    print(        f\"  └── Device-wide VRAM in use                                 : ~{device_vram_used:.2f} MB\"    )    print(\"-\" * 70 + \"\\n\")    print(f\"\\n[Step D] Dispatching {args.batches} inference tasks...\")    for task_id in range(args.batches):        batch = torch.randn(16, args.in_dim)        input_queue.put((task_id, batch))    print(f\"\\n[Step E] Receiving predictions from workers...\")    for _ in range(args.batches):        task_id, predictions, worker_rank = output_queue.get()        print(            f\"  └── Result Task #{task_id} from Worker {worker_rank} | Predictions: {predictions[:4].tolist()}...\"        )    print(\"\\n[Step F] Terminating workers cleanly...\")    for _ in range(args.workers):        input_queue.put(None)    for p in workers:        p.join()    print(\"\\n\" + \"=\" * 70)    print(\"ALL PROCESSES FINISHED SUCCESSFULLY\")    print(\"=\" * 70)if __name__ == '__main__':    main()\n```\n\n |\n\nTo run the example, we could use the following command to start an NVIDIA PyTorch container with GPU support.\n\n```\n1\n```\n\n | \n\n``` bash\n$ docker run -it --rm --gpus all --ipc=host --ulimit memlock=-1 --ulimit stack=67108864 -v $(pwd):/mnt -w /mnt nvcr.io/nvidia/pytorch:26.07-py3\n```\n\n |\n\nTo run the example, we could use the following commands to run the PyTorch or AOTInductor multi-process inference with weight sharing via CUDA IPC.\n\n```\n12\n```\n\n | \n\n``` bash\n$ python pytorch_inference_ipc.py --mode aoti --batches 128 --workers 24$ python pytorch_inference_ipc.py --mode pytorch --batches 128 --workers 24\n```\n\n |\n\nBy default, each model instance will take 2 GB of GPU VRAM. My GPU is an NVIDIA RTX 5080 with 16 GB of GPU VRAM. If there is no weight sharing, I would not be able to run the example with 24 workers successfully.\n\nThere are a few key operations or caveats in the example:\n\n- If using PyTorch multi-process inference, the model weights are shared across processes using\n`model.share_memory()`\n\n. This allows the model weights to be shared across processes without duplicating them in GPU VRAM. Although it seems that without using`model.share_memory()`\n\n, the`multiprocessing`\n\nmodule will still share the model weights across processes, but it is still a good practice to explicitly call`model.share_memory()`\n\nto ensure that the model weights are shared across processes. - To avoid materializing the model weights for AOTInductor packages in each worker process, to build the AOTInductor packages, we set the\n`aot_inductor.package_constants_in_so`\n\nto`False`\n\nin the`inductor_configs`\n\n. This will prevent the model weights from being packaged into the AOTInductor packages, and when the model is loaded in each worker process using`torch._inductor.aoti_load_package`\n\n, the no model weights will be loaded from the package. Consequently, the GPU VRAM will not be inflated by model loading from many processes. Then, we explicitly load the model weights from the parent process state dict using`runner.load_constants`\n\n. This will bind the model weights in the AOTInductor package to the shared model weights in the parent process, and avoid weight duplication in GPU VRAM. - The\n`runner.load_constants`\n\nfunction has a`user_managed`\n\nargument, which is set to`False`\n\nin the example. This means that the AOTInductor runner will not manage the model weights, and the user is responsible for managing the model weights. The AOTInductor runner will just have a pointer to the model weights in the parent process, and will not manage the life cycle of model weights. If`user_managed`\n\nis set to`True`\n\n, the AOTInductor runner will manage the model weights. But instead of making a copy of the model weights in GPU VRAM, the AOTInductor runner will still have a reference to the model weights in the parent process. The reference counter is effective across processes, so the model weights will not be freed until all references to the model weights are released. - In this example, the AOTInductor package was replicated for each worker process to break the OS mmap page sharing, in order to demonstrate the weight sharing via the parent process state dict. If we only have one AOTInductor package file, and each process loads the same package file, the OS will still share the same mmap pages for all processes, no model weights duplication will occur on GPU VRAM, because those loaded weights are read-only and the OS knows that it can be safely shared across processes.\n\nTorchScript Weight Sharing\n\nIf the user is still using the deprecated TorchScript, it is still possible to share model weights across processes using a similar approach. However, I encountered a lot of problems. For example, I would have to instantiate the TorchScript model on CPU in all the processes first and then replace the model weights with the shared weights from the parent process. This will inflate the CPU memory usages significantly, unless I load the TorchScript model and bind the shared weights in the parent process sequentially for each process. Consequently, I did not include the TorchScript example in the previous example implementation.\n\nProgram Dependency Duplication\n\nEven though we saved duplicate model weights in multi-process inference, the program dependencies (e.g., shared libraries) are still duplicated in each process. This is because each process has its own address space and loads its own copy of the program dependencies. However, the OS can still share the same physical memory pages for the program dependencies across processes, if those dependencies are loaded from the same shared library files. This is a common behavior in modern operating systems, and it helps to reduce the overall memory footprint of multi-process applications. Nevertheless, it is still expected that some memory overhead will be incurred for both host memory and GPU memory for each process, and the amount of overhead really depends.\n\nConclusions\n\nIn this blog post, we demonstrated how to share model weights across multiple processes in PyTorch multi-process inference using CUDA IPC. We also discussed the caveats and best practices for both PyTorch and AOTInductor, as well as the implications for TorchScript and program dependency duplication.\n\nPyTorch Multi-Process Inference Weight Sharing Via Inter-Process Communication\n\n[https://leimao.github.io/blog/PyTorch-IPC-Multi-Process-Inference-Weight-Sharing/](https://leimao.github.io/blog/PyTorch-IPC-Multi-Process-Inference-Weight-Sharing/)", "url": "https://wpnews.pro/news/pytorch-multi-process-inference-weight-sharing-via-inter-process-communication", "canonical_source": "https://leimao.github.io/blog/PyTorch-IPC-Multi-Process-Inference-Weight-Sharing/", "published_at": "2026-07-31 04:29:52.251204+00:00", "updated_at": "2026-07-31 04:29:54.316086+00:00", "lang": "en", "topics": ["machine-learning", "ai-infrastructure", "developer-tools"], "entities": ["PyTorch", "AOTInductor", "CUDA", "Global Interpreter Lock"], "alternates": {"html": "https://wpnews.pro/news/pytorch-multi-process-inference-weight-sharing-via-inter-process-communication", "markdown": "https://wpnews.pro/news/pytorch-multi-process-inference-weight-sharing-via-inter-process-communication.md", "text": "https://wpnews.pro/news/pytorch-multi-process-inference-weight-sharing-via-inter-process-communication.txt", "jsonld": "https://wpnews.pro/news/pytorch-multi-process-inference-weight-sharing-via-inter-process-communication.jsonld"}}