import math
from dataclasses import dataclass
import itertools
import logging
import os
from typing import List
import pytest
import torch
import torch_npu
import torch.multiprocessing as mp
import torch.distributed as dist
from torch import nn, Tensor
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader
from torchrec_embcache.distributed.embedding import EmbCacheEmbeddingCollection
from torchrec_embcache.distributed.configs import (EmbCacheEmbeddingConfig,
AdmitAndEvictConfig,
ShowClickParams,
AdmitAndEvictPolicyType)
from torchrec_embcache.distributed.train_pipeline import EmbCacheTrainPipelineSparseDist
from torchrec_embcache.distributed.sharding.embedding_sharder import EmbCacheEmbeddingCollectionSharder
import torchrec
import torchrec.distributed
from torchrec import EmbeddingCollection
from torchrec.optim.apply_optimizer_in_backward import apply_optimizer_in_backward
from torchrec.distributed.planner import (
EmbeddingShardingPlanner,
Topology,
ParameterConstraints,
)
from torchrec.distributed.types import ShardingEnv
from torchrec.optim.keyed import CombinedOptimizer
from torchrec import KeyedJaggedTensor
from dataset import RandomRecDataset, Batch
from model import ModelEc as Model
from util import setup_logging
_SAVE_PATH = "save_dir/sparse"
WORLD_SIZE_STR = str(os.environ.get("WORLD_SIZE", "2"))
WORLD_SIZE = int(WORLD_SIZE_STR) if WORLD_SIZE_STR.isalnum() else 2
LOOP_TIMES = 500
EVICT_STEP_INTERVAL = LOOP_TIMES // 4
BATCH_NUM = LOOP_TIMES
@dataclass
class ExecuteConfig:
world_size: int
table_num: int
embedding_dims: List[int]
num_embeddings: List[int]
sharding_type: str
lookup_len: int
device: str
enable_admit: bool
enable_evict: bool
def execute(rank: int, config: ExecuteConfig):
world_size = config.world_size
table_num = config.table_num
embedding_dims = config.embedding_dims
num_embeddings = config.num_embeddings
sharding_type = config.sharding_type
lookup_len = config.lookup_len
device = config.device
enable_admit = config.enable_admit
enable_evict = config.enable_evict
setup_logging(rank)
logging.info("this test %s", os.path.basename(__file__))
dataset = RandomRecDataset(BATCH_NUM, lookup_len, num_embeddings, table_num, is_enable_score=True)
dataset_golden = RandomRecDataset(BATCH_NUM, lookup_len, num_embeddings, table_num, is_enable_score=True)
data_loader_golden = DataLoader(
dataset_golden,
batch_size=None,
batch_sampler=None,
pin_memory=True,
pin_memory_device="npu",
num_workers=1,
)
data_loader = DataLoader(
dataset,
batch_size=None,
batch_sampler=None,
pin_memory=True,
pin_memory_device="npu",
num_workers=1,
)
embedding_configs = []
if enable_admit and enable_evict:
logging.info("enable admit and evict")
showclick_params = ShowClickParams(
alpha=1,
beta=1,
admit_threshold=0.1,
evict_percentage=0.1,
score_decay=0.9
)
elif enable_admit and not enable_evict:
logging.info("enable admit only")
showclick_params = ShowClickParams(
alpha=1,
beta=1,
admit_threshold=2.2,
evict_percentage=0.0,
score_decay=0.9
)
elif not enable_admit and enable_evict:
logging.info("enable evict only")
showclick_params = ShowClickParams(
alpha=1,
beta=1,
admit_threshold=0.0,
evict_percentage=0.1,
score_decay=0.9
)
for i in range(table_num):
admit_and_evict_config = AdmitAndEvictConfig(evict_step_interval=EVICT_STEP_INTERVAL,
not_admitted_default_value=0.999,
showclick_params=showclick_params,
policy_type=AdmitAndEvictPolicyType.POLICY_SHOWCLICK)
ec_config = EmbCacheEmbeddingConfig(
name=f"table{i}",
embedding_dim=embedding_dims[i],
num_embeddings=num_embeddings[i],
feature_names=[f"feat{i}"],
init_fn=weight_init,
weight_init_min=0.0,
weight_init_max=1.0,
admit_and_evict_config=admit_and_evict_config
)
embedding_configs.append(ec_config)
logging.info(f"showclick admit={admit_and_evict_config.is_feature_admit_enabled()}, \
evict={admit_and_evict_config.is_feature_evict_enabled()}, ")
test_model = TestModel(rank, world_size, device)
test_result_golden = []
test_result_golden = test_model.cpu_golden_loss(embedding_configs, data_loader_golden, 0, rank)
test_results = test_model.test_loss(embedding_configs, data_loader, sharding_type, enable_evict, training=True)
for i, result in enumerate(test_results):
logging.debug("")
logging.debug("==============batch %d================", i // 2)
logging.debug("result test %s", result)
if not enable_admit and enable_evict:
golden = test_result_golden[i]
logging.debug("golden test %s", golden)
assert torch.allclose(
golden, result, rtol=1e-04, atol=1e-04
), "golden and result is not closed"
dist.destroy_process_group()
def weight_init(param: torch.nn.Parameter):
if len(param.shape) != 2:
return
torch.manual_seed(param.shape[1])
result = torch.linspace(0, 1, steps=param.shape[1]).unsqueeze(0).repeat(param.shape[0], 1)
param.data.copy_(result)
def _get_init_weight(table_dims: List[int]):
init_embs = []
for dim in table_dims:
emb = torch.linspace(0, 1, steps=dim)
init_embs.append(emb)
return init_embs
def _get_init_optimizer_slot(table_dims: List[int]):
init_slots = []
for dim in table_dims:
slot = torch.zeros((dim,))
init_slots.append(slot)
return init_slots
class TestModel:
def __init__(self, rank, world_size, device):
self.rank = rank
self.world_size = world_size
self.device = device
self.pg_method = "hccl" if device == "npu" else "gloo"
if device == "npu":
torch_npu.npu.set_device(rank)
self.setup(rank=rank, world_size=world_size)
self.emb_configs: List[EmbCacheEmbeddingConfig] = []
self.count_for_table: List[List[dict]] = [[] for _ in range(world_size)]
self.label_for_table: List[List[dict]] = [[] for _ in range(world_size)]
self.score_for_table: List[List[dict]] = [[] for _ in range(world_size)]
def setup(self, rank: int, world_size: int):
os.environ["MASTER_ADDR"] = "127.0.0.1"
os.environ["MASTER_PORT"] = "6015"
dist.init_process_group(self.pg_method, rank=rank, world_size=world_size)
os.environ["LOCAL_RANK"] = f"{rank}"
def test_loss(
self,
embedding_configs: List[EmbCacheEmbeddingConfig],
dataloader: DataLoader[Batch],
sharding_type: str,
enable_evict: bool,
training: bool = True,
):
rank, world_size = self.rank, self.world_size
host_gp = dist.new_group(backend="gloo")
host_env = ShardingEnv(world_size=world_size, rank=rank, pg=host_gp)
table_num = len(embedding_configs)
ec = EmbCacheEmbeddingCollection(device=torch.device("meta"), tables=embedding_configs,
batch_size=2, multi_hot_sizes=[1] * table_num,
world_size=dist.get_world_size())
num_features = sum([c.num_features() for c in embedding_configs])
ec = Model(ec, num_features)
apply_optimizer_in_backward(
optimizer_class=torch.optim.Adagrad,
params=ec.parameters(),
optimizer_kwargs={"lr": 0.02, "eps": 1e-8},
)
constrains = {
f"table{i}": ParameterConstraints(sharding_types=[sharding_type], compute_kernels=['fused'])
for i in range(table_num)
}
rank = int(os.environ["LOCAL_RANK"])
npu_device: torch.device = torch.device(f"npu:{rank}")
cpu_device = torch.device("cpu")
cpu_pg = dist.new_group(backend="gloo")
cpu_env = ShardingEnv.from_process_group(cpu_pg)
hash_shader = EmbCacheEmbeddingCollectionSharder(
cpu_device=cpu_device,
cpu_env=cpu_env,
npu_device=npu_device,
npu_env=ShardingEnv.from_process_group(dist.GroupMember.WORLD),
)
shaders = [hash_shader]
planner = EmbeddingShardingPlanner(
topology=Topology(world_size=self.world_size, compute_device=self.device),
constraints=constrains,
)
plan = planner.collective_plan(
ec, shaders, dist.GroupMember.WORLD
)
if self.rank == 0:
logging.debug(plan)
ddp_model = torchrec.distributed.DistributedModelParallel(
ec,
sharders=shaders,
device=npu_device,
plan=plan,
)
logging.debug(ddp_model)
optimizer = CombinedOptimizer([ddp_model.fused_optimizer])
results = []
if training:
iter_ = iter(dataloader)
ddp_model.train()
evict_step_interval = EVICT_STEP_INTERVAL if enable_evict else None
pipe = EmbCacheTrainPipelineSparseDist(
ddp_model,
optimizer=optimizer,
cpu_device=cpu_device,
npu_device=npu_device,
return_loss=True,
evict_step_interval=evict_step_interval
)
for _ in range(LOOP_TIMES):
out, loss = pipe.progress(iter_)
results.append(out.detach().cpu())
results.append(loss.detach().cpu())
return results
def cpu_golden_loss(self, embedding_configs: List[EmbCacheEmbeddingConfig], dataloader: DataLoader[Batch],
evict_threshold: int, rank_id: int):
pg = dist.new_group(backend="gloo")
self.emb_configs = embedding_configs
table_num = len(embedding_configs)
ec = EmbeddingCollection(device=torch.device("cpu"), tables=embedding_configs)
num_features = sum([c.num_features() for c in embedding_configs])
ec_wrap = Model(ec, num_features)
model = DDP(ec_wrap, process_group=pg)
opt = torch.optim.Adagrad(model.parameters(), lr=0.02, eps=1e-8)
results = []
batch: Batch
iter_ = iter(dataloader)
for i in range(LOOP_TIMES):
batch = next(iter_)
opt.zero_grad()
loss, outputs = model(batch)
results.append(outputs.detach().cpu())
results.append(loss.detach().cpu())
loss.backward()
opt.step()
for rank_id in range(self.world_size):
self._static_key_count(batch, table_num, rank_id)
if LOOP_TIMES < 30:
for rank_id in range(self.world_size):
for table_index in range(table_num):
logging.info("mock all2all iterId:%d, rankId:%d, tableIndex:%d, count data:%s, label data:%s",
i, rank_id, table_index, self.count_for_table[rank_id][table_index],
self.label_for_table[rank_id][table_index])
for rank_id in range(self.world_size):
no_admit_ids = [[] for _ in range(table_num)]
self._record_showclick_info_cpu(batch, table_num, rank_id, i, no_admit_ids)
if self.emb_configs[0].admit_and_evict_config.is_feature_evict_enabled():
if i > 0 and (i + 1) % EVICT_STEP_INTERVAL == 0:
self._evict_embedding_cpu(evict_threshold, ec.embeddings, opt, i, rank_id)
return results
def _static_key_count(self, batch, table_num, rank_id):
sparse_tensor: KeyedJaggedTensor = batch.sparse_features
labels: Tensor = batch.click_labels
values = sparse_tensor.values()
offset_per_key = sparse_tensor.offset_per_key()
if len(self.count_for_table[rank_id]) == 0:
for _ in range(table_num):
self.count_for_table[rank_id].append(dict())
if len(self.label_for_table[rank_id]) == 0:
for _ in range(table_num):
self.label_for_table[rank_id].append(dict())
for table_index in range(table_num):
start = offset_per_key[table_index]
end = offset_per_key[table_index + 1]
values_per_table = values[start:end]
for index, ids in enumerate(values_per_table):
ids = ids.item()
if ids % WORLD_SIZE != rank_id:
continue
if ids in self.count_for_table[rank_id][table_index]:
self.count_for_table[rank_id][table_index][ids] = \
self.count_for_table[rank_id][table_index][ids] + 1 * 2
else:
self.count_for_table[rank_id][table_index][ids] = 1 * 2
if ids in self.label_for_table[rank_id][table_index]:
self.label_for_table[rank_id][table_index][ids] = \
self.label_for_table[rank_id][table_index][ids] + labels[index].item() * 2
else:
self.label_for_table[rank_id][table_index][ids] = labels[index].item() * 2
if LOOP_TIMES < 30:
logging.debug("static key count rankId:%d, tableIndex:%d, count:%s, label:%s",
rank_id, table_index, self.count_for_table[rank_id][table_index],
self.label_for_table[rank_id][table_index])
def _record_showclick_info_cpu(self, batch, table_num, rank_id, batch_id, no_admit_ids):
sparse_tensor: KeyedJaggedTensor = batch.sparse_features
values = sparse_tensor.values()
offset_per_key = sparse_tensor.offset_per_key()
if len(self.score_for_table[rank_id]) == 0:
for _ in range(table_num):
self.score_for_table[rank_id].append(dict())
for table_index in range(table_num):
start = offset_per_key[table_index]
end = offset_per_key[table_index + 1]
values_per_table = values[start:end]
show_click_params = self.emb_configs[table_index].admit_and_evict_config.showclick_params
processed_ids = []
for ids in values_per_table:
ids = ids.item()
if ids % WORLD_SIZE != rank_id:
continue
if ids in processed_ids:
continue
processed_ids.append(ids)
admit_score = show_click_params.alpha * self.count_for_table[rank_id][table_index][ids] + \
show_click_params.beta * self.label_for_table[rank_id][table_index][ids]
if self.emb_configs[table_index].admit_and_evict_config.is_feature_admit_enabled():
if admit_score < show_click_params.admit_threshold:
no_admit_ids[table_index].append(ids)
continue
if self.emb_configs[table_index].admit_and_evict_config.is_feature_evict_enabled():
if ids not in self.score_for_table[rank_id][table_index]:
self.score_for_table[rank_id][table_index][ids] = admit_score
else:
old_score = self.score_for_table[rank_id][table_index][ids]
new_score = (old_score + admit_score) * show_click_params.score_decay
self.score_for_table[rank_id][table_index][ids] = new_score
if rank_id == self.rank and LOOP_TIMES < 30:
logging.debug("record showclick info rankId:%d, batchId:%d, tableIndex:%d, score data:%s",
rank_id, batch_id, table_index, self.score_for_table[rank_id][table_index])
def _evict_embedding_cpu(self, evict_threshold: int, embeddings: nn.ModuleDict,
opt: torch.optim.Adagrad, batch_id: int, rank_id: int):
logging.info("Start cpu embedding evict, current step:%d, rank_id:%d", batch_id, rank_id)
emb_dims: List[int] = [c.embedding_dim for c in self.emb_configs]
table_names = [c.name for c in self.emb_configs]
table_num = len(table_names)
emb_init_values: List[Tensor] = _get_init_weight(emb_dims)
optimizer_init_values: List[Tensor] = _get_init_optimizer_slot(emb_dims)
for table_index in range(table_num):
sorted_score = sorted(self.score_for_table[rank_id][table_index].items(), key=lambda x: (x[1], x[0]))
evict_percentage = self.emb_configs[table_index].admit_and_evict_config.showclick_params.evict_percentage
evict_num = math.floor(len(sorted_score) * evict_percentage)
if evict_num == 0 and len(sorted_score) > 0:
evict_num = 1
evict_ids_per_table, evict_ids_score_per_table = zip(*sorted_score[:evict_num])
table_name = table_names[table_index]
op_t = opt.param_groups[0]["params"][table_index]
slot_tensor = opt.state[op_t]["sum"]
for ids in evict_ids_per_table:
self.count_for_table[rank_id][table_index].pop(ids)
self.score_for_table[rank_id][table_index].pop(ids)
self.label_for_table[rank_id][table_index].pop(ids)
with torch.no_grad():
embeddings[table_name].weight[ids].data.copy_(emb_init_values[table_index])
slot_tensor[ids].data.copy_(optimizer_init_values[table_index])
if LOOP_TIMES < 30:
logging.info("batchId:%d, table name:%s, evict ids num:%d, rank_id:%d, evict ids:%s, evict scores:%s",
batch_id, table_name, len(evict_ids_per_table),
rank_id, evict_ids_per_table, evict_ids_score_per_table)
params = {
"world_size": [WORLD_SIZE],
"table_num": [2],
"embedding_dims": [[128, 128]],
"num_embeddings": [[4000, 400]],
"sharding_type": ["row_wise"],
"lookup_len": [128],
"device": ["npu"],
"enable_admit": [True],
"enable_evict": [True],
}
@pytest.mark.parametrize("config", [
ExecuteConfig(*v) for v in itertools.product(*params.values())
])
def test_hstu_dens_normal(config: ExecuteConfig):
mp.spawn(
execute,
args=(config,),
nprocs=WORLD_SIZE,
join=True,
)
params = {
"world_size": [WORLD_SIZE],
"table_num": [2],
"embedding_dims": [[128, 128]],
"num_embeddings": [[4000, 400]],
"sharding_type": ["row_wise"],
"lookup_len": [128],
"device": ["npu"],
"enable_admit": [True],
"enable_evict": [False],
}
@pytest.mark.parametrize("config", [
ExecuteConfig(*v) for v in itertools.product(*params.values())
])
@pytest.mark.skip(reason="暂时无法通过loss进行正确性验证,待保存和加载功能完善后再启用该测试," \
"将保存的admit key count与手动统计的key count进行对比验证")
def test_admit_count_correctness(config: ExecuteConfig):
mp.spawn(
execute,
args=(config,),
nprocs=WORLD_SIZE,
join=True,
)
params = {
"world_size": [WORLD_SIZE],
"table_num": [2],
"embedding_dims": [[128, 128]],
"num_embeddings": [[4000, 400]],
"sharding_type": ["row_wise"],
"lookup_len": [128],
"device": ["npu"],
"enable_admit": [False],
"enable_evict": [True],
}
@pytest.mark.parametrize("config", [
ExecuteConfig(*v) for v in itertools.product(*params.values())
])
def test_evict_correctness(config: ExecuteConfig):
mp.spawn(
execute,
args=(config,),
nprocs=WORLD_SIZE,
join=True,
)