# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # ruff: noqa: F401, F841 """Test sharded loader""" # pylint: disable=missing-docstring import json import tempfile import numpy as np import pytest from tvm_ffi import Shape, register_global_func import tvm import tvm.testing from tvm import relax as rx from tvm.contrib import tvmjs from tvm.runtime import disco as di from tvm.s_tir import dlight as dl from tvm.script import ir as I from tvm.script import relax as R from tvm.target import Target from tvm.testing import env # `runtime.disco.compiled_ccl` is registered together with the CCL runtime # functions, so its absence means the disco CCL runtime is not in this build. _compiled_ccl = tvm.get_global_func("runtime.disco.compiled_ccl", allow_missing=True) if _compiled_ccl is None or _compiled_ccl() != "nccl": pytest.skip("Disco NCCL support is not available", allow_module_level=True) # All tests in this file shard across two GPUs. pytestmark = [ pytest.mark.skipif(not env.has_multi_gpu(), reason="need multiple gpus"), ] def _run_with_nccl_session(devices, func): def run_and_check(): sess = di.ThreadedSession(num_workers=len(devices)) try: sess.init_ccl("nccl", *devices) return func(sess) finally: sess.shutdown() return tvm.testing.run_with_gpu_lock(run_and_check) @register_global_func("tests.disco.shard_dim_0", override=True) def _shard_dim_0(src, num_shards, tgt): s_0, s_1 = src.shape tgt.copyfrom(src.numpy().reshape(num_shards, s_0 // num_shards, s_1)) @register_global_func("tests.disco.shard_dim_1", override=True) def _shard_dim_1(src, num_shards, tgt): s_0, s_1 = src.shape tgt.copyfrom(src.numpy().reshape(s_0, num_shards, s_1 // num_shards).transpose(1, 0, 2)) @register_global_func("tests.disco.shard_qkv_0", override=True) def _shard_qkv_0(src, num_shards, q_heads, kv_heads, tgt): total_dim, hidden_size = src.shape head_dim = total_dim // (q_heads + kv_heads + kv_heads) q_dim = q_heads * head_dim kv_dim = kv_heads * head_dim w_q = src.numpy()[:q_dim, :].reshape( num_shards, q_heads // num_shards, head_dim, hidden_size, ) w_k = src.numpy()[q_dim : q_dim + kv_dim, :].reshape( num_shards, kv_heads // num_shards, head_dim, hidden_size, ) w_v = src.numpy()[q_dim + kv_dim :, :].reshape( num_shards, kv_heads // num_shards, head_dim, hidden_size, ) w_qkv = np.concatenate([w_q, w_k, w_v], axis=1) tgt.copyfrom(w_qkv) @register_global_func("tests.disco.shard_qkv_1", override=True) def _shard_qkv_1(src, tgt): s, _, _, h = src.shape # pylint: disable=invalid-name tgt.copyfrom(src.numpy().reshape(s, -1, h)) def _create_loader(sess, path, param_dict, shard_info): path_tensor_cache = path + "/tensor-cache.json" tvmjs.dump_tensor_cache(param_dict, path, encode_format="raw") with open(path_tensor_cache, encoding="utf-8") as i_f: tensor_cache = i_f.read() loader_create = sess.get_global_func("runtime.disco.ShardLoader") loader = loader_create(path_tensor_cache, tensor_cache, json.dumps(shard_info), None) return loader def _simulate_presharded_weights(base_path, param_dict, num_shards, shard_info): """Create fake weights to simulate those produced MLC-LLM's pre-sharding""" sharded_params = {} for key, ndarray in param_dict.items(): assert key in shard_info, f"ShardInfo lacks shard info about param: {key}" shard_dim = shard_info[key] sharded_params[key] = [ tvm.runtime.tensor(np_shard) for np_shard in np.split(ndarray, num_shards, axis=shard_dim) ] # Re-order so that the parameter order is sorted first by shard, # then by parameter. This matches the ordering used by MLC-LLM, # and avoids having *.bin files that must be accessed by more than # one worker. sharded_params = { f"{key}_shard-{i + 1}-of-{num_shards}": shards[i] for i in range(num_shards) for key, shards in sharded_params.items() } tvmjs.dump_tensor_cache( sharded_params, base_path, encode_format="raw", ) def test_load_shard(): devices = [0, 1] num_shards = len(devices) param_dict = { "x_0": np.random.uniform(size=[64, 128]).astype("float16"), "x_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = { "x_0": [ [ "tests.disco.shard_dim_1", [(num_shards, 64, 64), "float16"], num_shards, ], ], "x_1": [ [ "tests.disco.shard_dim_0", [(num_shards, 16, 128), "float32"], num_shards, ] ], } with tempfile.TemporaryDirectory() as path: def run_test(sess): loader = _create_loader(sess, path, param_dict, shard_info) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoad") d_0 = loader_load(loader, Shape([0])) d_1 = loader_load(loader, Shape([1])) np.testing.assert_equal( param_dict["x_0"][:, 0:64], d_0.debug_get_from_remote(0).numpy(), ) np.testing.assert_equal( param_dict["x_0"][:, 64:128], d_0.debug_get_from_remote(1).numpy(), ) np.testing.assert_equal( param_dict["x_1"][0:16, :], d_1.debug_get_from_remote(0).numpy(), ) np.testing.assert_equal( param_dict["x_1"][16:32, :], d_1.debug_get_from_remote(1).numpy(), ) _run_with_nccl_session(devices, run_test) def _create_presharded_loader(sess, path): path_tensor_cache = path + "/tensor-cache.json" with open(path_tensor_cache, encoding="utf-8") as i_f: tensor_cache = i_f.read() loader_create = sess.get_global_func("runtime.disco.ShardLoader") loader = loader_create(path_tensor_cache, tensor_cache, json.dumps({}), None) return loader def test_load_presharded(): devices = [0, 1] param_dict = { "x_0": np.random.uniform(size=[64, 128]).astype("float16"), "x_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = { "x_0": 1, "x_1": 0, } with tempfile.TemporaryDirectory() as path: _simulate_presharded_weights(path, param_dict, len(devices), shard_info) def run_test(sess): loader = _create_presharded_loader(sess, path) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoadPresharded") d_0 = loader_load(loader, Shape([0])) d_1 = loader_load(loader, Shape([1])) np.testing.assert_equal( param_dict["x_0"][:, 0:64], d_0.debug_get_from_remote(0).numpy(), ) np.testing.assert_equal( param_dict["x_0"][:, 64:128], d_0.debug_get_from_remote(1).numpy(), ) np.testing.assert_equal( param_dict["x_1"][0:16, :], d_1.debug_get_from_remote(0).numpy(), ) np.testing.assert_equal( param_dict["x_1"][16:32, :], d_1.debug_get_from_remote(1).numpy(), ) _run_with_nccl_session(devices, run_test) def test_load_shard_in_relax(): devices = [0, 1] num_shards = len(devices) param_dict = { "x_0": np.random.uniform(size=[64, 128]).astype("float16"), "x_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = { "x_0": [ [ "tests.disco.shard_dim_1", [(num_shards, 64, 64), "float16"], num_shards, ], ], "x_1": [ [ "tests.disco.shard_dim_0", [(num_shards, 16, 128), "float32"], num_shards, ] ], } # pylint: disable=invalid-name @I.ir_module class Module: # pylint: disable=too-few-public-methods @R.function def main( loader: R.Any, ) -> R.Tuple(R.Tensor((64, 64), "float32"), R.Tensor((16, 128), "float32")): R.func_attr({"global_symbol": "main"}) with R.dataflow(): lv0: R.Tensor((64, 64), "float32") = R.call_pure_packed( "runtime.disco.ShardLoaderLoad", loader, R.shape([0]), ty_args=R.Tensor((64, 64), "float32"), ) lv1: R.Tensor((16, 128), "float32") = R.call_pure_packed( "runtime.disco.ShardLoaderLoad", loader, R.shape([1]), ty_args=R.Tensor((16, 128), "float32"), ) lv2 = R.tuple(lv0, lv1) R.output(lv2) return lv2 # pylint: enable=invalid-name def relax_build(mod, target): with target: mod = rx.get_pipeline("zero")(mod) # pylint: disable=no-value-for-parameter return tvm.compile(mod, target="cuda") target = Target( { "kind": "cuda", "max_shared_memory_per_block": 49152, "max_threads_per_block": 1024, "thread_warp_size": 32, "registers_per_block": 65536, "arch": "sm_80", } ) with tempfile.TemporaryDirectory() as tmpdir: dso_path = tmpdir + "/test.so" relax_build(Module, target).export_library(dso_path) def run_test(sess): mod = sess.load_vm_module(dso_path) loader = _create_loader(sess, tmpdir, param_dict, shard_info) result = mod["main"](loader) np.testing.assert_equal( param_dict["x_0"][:, 0:64], result.debug_get_from_remote(0)[0].numpy(), ) np.testing.assert_equal( param_dict["x_0"][:, 64:128], result.debug_get_from_remote(1)[0].numpy(), ) np.testing.assert_equal( param_dict["x_1"][0:16, :], result.debug_get_from_remote(0)[1].numpy(), ) np.testing.assert_equal( param_dict["x_1"][16:32, :], result.debug_get_from_remote(1)[1].numpy(), ) _run_with_nccl_session(devices, run_test) def test_load_shard_all(): devices = [0, 1] num_shards = len(devices) param_dict = { "param_0": np.random.uniform(size=[64, 128]).astype("float16"), "param_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = { "param_0": [ [ "tests.disco.shard_dim_1", [(num_shards, 64, 64), "float16"], num_shards, ], ], "param_1": [ [ "tests.disco.shard_dim_0", [(2, 16, 128), "float32"], num_shards, ] ], } with tempfile.TemporaryDirectory() as path: def run_test(sess): loader = _create_loader(sess, path, param_dict, shard_info) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoadAll") params = loader_load(loader) p_0 = params.debug_get_from_remote(0) p_1 = params.debug_get_from_remote(1) np.testing.assert_equal(param_dict["param_0"][:, 0:64], p_0[0].numpy()) np.testing.assert_equal(param_dict["param_0"][:, 64:128], p_1[0].numpy()) np.testing.assert_equal(param_dict["param_1"][0:16, :], p_0[1].numpy()) np.testing.assert_equal(param_dict["param_1"][16:32, :], p_1[1].numpy()) _run_with_nccl_session(devices, run_test) def test_load_all_presharded(): devices = [0, 1] num_shards = len(devices) param_dict = { "param_0": np.random.uniform(size=[64, 128]).astype("float16"), "param_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = { "param_0": 0, "param_1": 1, } with tempfile.TemporaryDirectory() as path: _simulate_presharded_weights(path, param_dict, len(devices), shard_info) def run_test(sess): loader = _create_presharded_loader(sess, path) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoadAllPresharded") params = loader_load(loader) p_0 = params.debug_get_from_remote(0) p_1 = params.debug_get_from_remote(1) np.testing.assert_equal(param_dict["param_0"][0:32, :], p_0[0].numpy()) np.testing.assert_equal(param_dict["param_0"][32:64, :], p_1[0].numpy()) np.testing.assert_equal(param_dict["param_1"][:, 0:64], p_0[1].numpy()) np.testing.assert_equal(param_dict["param_1"][:, 64:128], p_1[1].numpy()) _run_with_nccl_session(devices, run_test) def test_load_shard_broadcast(): devices = [0, 1] param_dict = { "param_0": np.random.uniform(size=[64, 128]).astype("float16"), "param_1": np.random.uniform(size=[32, 128]).astype("float32"), } shard_info = {} with tempfile.TemporaryDirectory() as path: def run_test(sess): loader = _create_loader(sess, path, param_dict, shard_info) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoadAll") params = loader_load(loader) p_0 = params.debug_get_from_remote(0) p_1 = params.debug_get_from_remote(1) np.testing.assert_equal(param_dict["param_0"], p_0[0].numpy()) np.testing.assert_equal(param_dict["param_0"], p_1[0].numpy()) np.testing.assert_equal(param_dict["param_1"], p_0[1].numpy()) np.testing.assert_equal(param_dict["param_1"], p_1[1].numpy()) _run_with_nccl_session(devices, run_test) def test_load_qkv_proj_shard(): # pylint: disable=too-many-locals devices = [0, 1] num_shards = len(devices) q_heads = 8 kv_heads = 10 head_dim = 10 hidden_size = 20 w_q = np.random.uniform(size=[q_heads * head_dim, hidden_size]).astype("float16") w_k = np.random.uniform(size=[kv_heads * head_dim, hidden_size]).astype("float16") w_v = np.random.uniform(size=[kv_heads * head_dim, hidden_size]).astype("float16") w_qkv = np.concatenate([w_q, w_k, w_v], axis=0) param_dict = {"w_qkv": w_qkv} np_qkv = np.concatenate( [ w_q.reshape((num_shards, q_heads // num_shards, head_dim, hidden_size)), w_k.reshape((num_shards, kv_heads // num_shards, head_dim, hidden_size)), w_v.reshape((num_shards, kv_heads // num_shards, head_dim, hidden_size)), ], axis=1, ).reshape((num_shards, -1, hidden_size)) shard_info = { "w_qkv": [ [ "tests.disco.shard_qkv_0", [ (num_shards, (q_heads + kv_heads * 2) // num_shards, head_dim, hidden_size), "float16", ], num_shards, q_heads, kv_heads, ], [ "tests.disco.shard_qkv_1", [ (num_shards, (q_heads + kv_heads * 2) // num_shards * head_dim, hidden_size), "float16", ], ], ], } with tempfile.TemporaryDirectory() as path: def run_test(sess): loader = _create_loader(sess, path, param_dict, shard_info) loader_load = sess.get_global_func("runtime.disco.ShardLoaderLoad") d_0 = loader_load(loader, Shape([0])) np.testing.assert_equal( np_qkv[0], d_0.debug_get_from_remote(0).numpy(), ) np.testing.assert_equal( np_qkv[1], d_0.debug_get_from_remote(1).numpy(), ) _run_with_nccl_session(devices, run_test) if __name__ == "__main__": tvm.testing.main()