Prhub

#40714 [CPU] Create Proper Numa topology for s390x

原始 PR 作者 R3hankhan123 合并时间 2026-07-13 12:58 文件变更 7 提交数 6 评论 16 代码增减 +84 / -105

执行摘要

为 s390x 构建层级 NUMA 拓扑以优化 TP 性能

s390x 的核层次结构为 drawer > book > socket > core,之前仅考虑 socket 层级,导致 TP 场景下跨 book 绑定核心,性能受损。本 PR 创建正确的 NUMA 拓扑以修复此问题。

建议阅读此 PR 以了解如何为异构 CPU 架构适配 NUMA 感知的核绑定。特别值得关注的点:cpu_resource_utils.py 中通过 numa_node 字段复用现有分组机制的设计权衡,以及 ompmultiprocessing.py 中根据 CPU 列表直接派生拓扑域以绕开物理内存节点的思路。虽然缺少自动化测试,但核心逻辑以注释和 warning 记录了假设,整体改动清晰。

讨论亮点

BigPYJ1151 指出通用代码路径改动过多,建议通过复用 LogicalCPUInfo.numa_node 作为抽象分组键来减少侵入(如将 book/socket 映射到 numa_node 字段),作者采纳并简化了实现。另外 BigPYJ1151 质疑是否所有层级都必要,作者回应去掉 drawer 但仍保留 book,因为实测对 TP 性能有益。BigPYJ1151 要求对内存节点 fallback 添加日志,作者添加了 logger.warning

实现拆解

  1. 拓扑信息获取改造vllm/utils/cpu_resource_utils.py):在 _get_cpu_list 中,当检测到平台为 s390x 时,使用 lscpu -J -e=CPU,CORE,NODE,SOCKET,BOOK 获取扩展字段。解析后统计 book 和 socket 数量,优先选择 book(若多于一个 distinct 值)作为分组键,否则尝试 socket;若两者都无区分则回退通用路径。选定的分组键值被写入 LogicalCPUInfo.numa_node 字段,后续逻辑透明使用。

  2. 内存节点初始化适配vllm/v1/worker/cpu_worker.py):CPUWorker.__init__ 中,由于 numa_node 在 s390x 上可能是合成的 book ID(不映射物理内存节点),新增检查:若 cpu_core.numa_nodeallowed_memory_nodes 中则直接使用,否则发出警告并回退到 allowed_memory_nodes[0]。这避免了 C 侧内存初始化使用不存在的节点号。

  3. OpenMP 自动绑核适配vllm/utils/ompmultiprocessing.py):_get_autobind_cpu_ids 中,当 CPU 架构为 s390x 时,从逻辑 CPU 列表的 numa_node 值直接推导允许的拓扑域(allowed_numa_nodes),替代原先通过环境变量 CPU_VISIBLE_MEMORY_NODES 获取物理内存节点的路径。这使得绑核逻辑以拓扑分组(book/socket)而非物理 NUMA 节点进行,与 cpu_resource_utils 的映射保持一致。

  4. 平台感知扩展vllm/platforms/cpu.pyvllm/distributed/device_communicators/cpu_communicator.py):在需要 LD_PRELOAD libgomp 的架构列表中加入 CpuArchEnum.S390X,并在支持 SHM 通信器的架构列表中加入 S390X,确保进程间通信和 OpenMP 绑定在 s390x 上正确工作。

  5. Docker 构建简化docker/Dockerfile.s390x):移除了 Apache Arrow 构建阶段,升级 numba 到 0.65.0 并直接依赖 llvmlite 0.47.0,精简了构建流程。

文件 模块 状态 重要度
vllm/utils/cpu_resource_utils.py 资源工具 modified 6.92
vllm/v1/worker/cpu_worker.py CPU 工作器 modified 6.08
vllm/utils/ompmultiprocessing.py OMP 绑定 modified 5.84
vllm/platforms/cpu.py 平台层 modified 5.07
vllm/distributed/device_communicators/cpu_communicator.py 通信器 modified 4.18
docker/Dockerfile.s390x 构建脚本 modified 4.98
requirements/common.txt 依赖文件 modified 1.32

关键符号

_get_cpu_list CPUWorker.__init__ OMPMultiprocessing._get_autobind_cpu_ids

关键源码片段

vllm/utils/cpu_resource_utils.py core-logic

核心改动:扩展 `_get_cpu_list` 以在 s390x 上解析 book/socket 拓扑并映射到 numa_node,实现分层 CPU 分组。

# vllm/utils/cpu_resource_utils.py
import platform
from dataclasses import dataclass
from typing import Any@dataclass
class LogicalCPUInfo:
    id: int = -1
    physical_core: int = -1
    numa_node: int = -1 # 在 s390x 上可能映射为 book/socket ID
​
    @staticmethod
    def json_decoder(obj_dict: dict) -> dict:
        # 从 lscpu JSON 行提取字段
        return {
            "id": int(obj_dict.get("cpu", -1)),
            "physical_core": int(obj_dict.get("core", -1)),
            "numa_node": int(obj_dict.get("node", -1)),
        }def _get_cpu_list() -> list[LogicalCPUInfo]:
    # ... 前置判断(macOS 等)...
    if platform.machine() == "s390x":
        # s390x 上使用扩展列 SOCKET, BOOK
        lscpu_output = subprocess.check_output(
            "lscpu -J -e=CPU,CORE,NODE,SOCKET,BOOK", shell=True, text=True
        )
    else:
        lscpu_output = subprocess.check_output(
            "lscpu --json --extended=CPU,CORE,NODE --online", shell=True, text=True
        )
​
    # 处理 NUMA undefined(映射为 0)
    lscpu_output = re.sub(r'"node":\s*-\s*(,|\n|\})', r'"node": 0\1', lscpu_output)
​
    if platform.machine() == "s390x":
        # 处理 book/socket 中可能的 "-"
        lscpu_output = re.sub(r'"book":\s*-\s*(,|\n|\})', r'"book": 0\1', lscpu_output)
        lscpu_output = re.sub(r'"socket":\s*-\s*(,|\n|\})', r'"socket": 0\1', lscpu_output)
​
        raw_cpus = json.loads(lscpu_output)["cpus"]
        # 收集 distinct values
        distinct_books = {LogicalCPUInfo._int(str(e.get("book", "-1"))) for e in raw_cpus} - {-1}
        distinct_sockets = {LogicalCPUInfo._int(str(e.get("socket", "-1"))) for e in raw_cpus} - {-1}
​
        # 选择最佳分组:优先 book(若多于 1 个),否则 socket
        if len(distinct_books) > 1:
            group_key = "book"
        elif len(distinct_sockets) > 1:
            group_key = "socket"
        else:
            group_key = None
​
        if group_key is not None:
            result = []
            for entry in raw_cpus:
                cpu_id = LogicalCPUInfo._int(str(entry.get("cpu", "-1")))
                core = LogicalCPUInfo._int(str(entry.get("core", "-1")))
                group = LogicalCPUInfo._int(str(entry.get(group_key, "-1")))
                if -1 not in (cpu_id, core, group):
                    # 将分组键值写入 numa_node,复用后续所有 NUMA 亲和逻辑
                    result.append(LogicalCPUInfo(id=cpu_id, physical_core=core, numa_node=group))
            if result:
                return result
        # 若没有有效分组,回退通用路径
    # 通用路径(非 s390x 或 s390x 无分组)
    # ...
vllm/v1/worker/cpu_worker.py core-logic

使用者适配:处理 s390x 上 numa_node 可能为合成 book ID 的情况,回滚到真实内存节点。

# vllm/v1/worker/cpu_worker.py
class CPUWorker(Worker):
    def __init__(self, vllm_config, local_rank, rank, distributed_init_method, is_driver_worker=False):
        allowed_memory_nodes = get_visible_memory_node()
        allowed_cpu_list = get_allowed_cpu_list()
        cpu_core = allowed_cpu_list[0]
​
        # s390x 上 numa_node 可能是 book ID,不一定是真实内存节点
        if cpu_core.numa_node in allowed_memory_nodes:
            memory_node = cpu_core.numa_node
        else:
            # 回退到第一个可用的内存节点,并记录警告
            logger.warning(
                "CPU group key %s is not a valid memory node. "
                "Falling back to memory node %s.",
                cpu_core.numa_node,
                allowed_memory_nodes[0],
            )
            memory_node = allowed_memory_nodes[0]
​
        torch.ops._C.init_cpu_memory_env([memory_node])
        # 后续内存查询也使用回退后的 memory_node
        memory_status = get_memory_node_info(memory_node)
        # ...
vllm/utils/ompmultiprocessing.py core-logic

适配自动绑核:s390x 上从 CPU 列表的 numa_node 推导拓扑域,代替内存节点环境变量。

# vllm/utils/ompmultiprocessing.py
class OMPMultiprocessing:
    def _get_autobind_cpu_ids(self, cpu_selector):
        allowed_numa_nodes = cr_utils.get_visible_memory_node()
        logical_cpu_list = cr_utils.get_allowed_cpu_list()
        local_world_size = self.local_world_size
​
        # s390x 上 numa_node 已被重映射为拓扑分组键(book/socket)
        # 不再对应物理内存节点,改为从 CPU 列表派生域
        cpu_arch = current_platform.get_cpu_architecture()
        if cpu_arch == CpuArchEnum.S390X:
            # 使用所有出现在 CPU 列表中的不同 numa_node 值作为拓扑域
            allowed_numa_nodes = sorted(set(cpu.numa_node for cpu in logical_cpu_list))
​
        assert len(allowed_numa_nodes) >= local_world_size or self.simulate_multi_node, (
            f"Not enough allowed NUMA nodes to bind threads of "
            f"{local_world_size} local CPUWorkers. "
            f"Allowed NUMA nodes are {allowed_numa_nodes}."
        )
        # 后续按 allowed_numa_nodes 分配每个 rank 的 CPU 列表
        # ...

评论区精华

通用代码路径改动过多 设计

bigPYJ1151 指出 `_get_cpu_list` 改动侵入通用路径,建议重用 `numa_node` 作为抽象分组键以减少改动量。

结论:作者简化实现,将 book/socket 映射到 `numa_node`,利用现有分组模式。 · 已解决

支持层级深度 设计

bigPYJ1151 询问是否所有层级(drawer/book/socket/core)都需要,认为对深度学习负载通常存在一个最佳层级。

结论:作者去掉 drawer,只保留 book 和 socket,因为 book 对 TP 有益且实测性能好。 · 已解决

Synthetic numa_node 回退警告 测试

bigPYJ1151 要求当 s390x 的 numa_node 不映射物理内存节点时添加日志,以便 future refinement。

结论:作者在 `cpu_worker.py` 中添加了 `logger.warning`。 · 已解决

风险与影响

主要风险在通用代码路径的修改(_get_cpu_list)对非 s390x 平台的影响,但改动封闭在 platform.machine() == 's390x' 分支内,回归风险较低。s390x 上若 book ID 与物理内存节点不一致,回退逻辑可能掩盖配置错误,导致内存初始化使用错误的节点(虽然健康检查会捕获)。另外缺少自动化测试覆盖,只能依赖人工镜像测试。Dockerfile 的大幅删减可能破坏已有镜像的 Arrow 依赖,但清理动机明确。

对用户:s390x 上 TP 和 DP 场景的推理性能显著改善(具体数值未提供,但 PR 作者展示绑核日志显示核分配正确)。对系统:改进了 CPU 资源管理工具的可扩展性,未来其他架构可参考加入自定义拓扑分组。对团队:维护者 BigPYJ1151 深度参与了设计和代码简化,减少了后续维护负担。

缺少测试覆盖 平台特定路径回归风险低 Docker 依赖变更可能回滚

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论