尧图精选

超异构芯片调度:从时间片轮转到数据流驱动

🕒 发布时间:2026/9/10 1:56:47 📁 来源:尧图网络
1. 一颗芯片装下 CPUGPUNPU这不是集成是重构计算的底层逻辑“一颗芯片装下 CPUGPUNPU”——这句话听上去像营销话术但当你拆开昇腾910B、寒武纪MLU370、或者高通骁龙8 Gen3的芯片手册你会发现它早已不是愿景而是正在发生的物理现实。我亲手拆过三款带NPU的SoC样品用热成像仪拍过它们在跑大模型推理时的功耗热图CPU核心温区、GPU渲染单元温区、NPU张量计算阵列温区三个区域温度曲线完全独立升温斜率不同峰值时间错开说明它们真正在同一块硅片上并行工作而不是靠PCIe总线硬拉。所谓“超异构”核心不在“多”而在“异”——CPU擅长分支跳转与通用控制GPU强于规则数据并行NPU专精低比特稀疏矩阵乘加三者指令集、内存访问模式、功耗响应特性全都不一样。传统调度器比如Linux CFS只认“任务优先级”和“CPU时间片”它根本不知道一个LLM推理请求里前10ms该喂给CPU做token解码中间200ms该塞进GPU做KV缓存搬运后50ms必须压到NPU上跑INT4量化矩阵乘——这就像让一个只会调火车时刻表的调度员去指挥一支海陆空联合作战部队。所以“怎么调度”本质是问当硬件层已经把三种完全不同的计算引擎焊死在同一块硅上软件层如何不靠“猜”而靠“算”出每一毫秒该把哪段数据、以什么精度、喂给哪个引擎这背后牵扯的是内存一致性协议的重定义、中断响应路径的重构、甚至编译器IR中间表示层面的算子切分策略。我见过太多团队卡在这一步硬件性能参数标得天花乱坠实测跑Stable Diffusion却卡在CPU-GPU数据搬运上显存带宽利用率不到30%因为调度器还在用十年前的“先占式轮转”逻辑。这篇文章不讲芯片制程或晶体管数量只聚焦一个工程师每天要面对的真实问题当你手头真有一颗集成了CPU/GPU/NPU的芯片怎么写代码、配系统、调参数才能让这颗芯片的算力不互相打架反而拧成一股绳下面所有内容都来自我在边缘AI盒子、车载域控制器、以及国产AI服务器三个真实项目里踩过的坑、测过的数据、改过的内核补丁。2. 超异构调度的本质从“时间片轮转”到“数据流驱动”的范式迁移2.1 为什么传统调度器在超异构芯片上必然失效先说个血泪教训去年我们给某车企做智驾域控升级换上一颗集成4核A762核X316TOPS NPU的芯片。按理论算力目标检测模型推理延迟该从85ms降到42ms。结果实测反而升到93ms。抓取perf数据一看CPU在等GPU完成DMA拷贝GPU在等NPU释放共享内存锁NPU又在等CPU下发新的算子配置——三者像三辆堵在同一个路口的车谁也动不了。根源在于传统调度器的设计哲学它把所有计算资源抽象成“可抢占的CPU时间片”调度单位是“进程/线程”决策依据是“优先级运行时间”。但在超异构芯片里这个抽象彻底崩塌了。举三个关键差异资源不可互换性一个GPU shader core不能执行CPU的分支预测指令一个NPU的脉动阵列无法处理GPU的纹理采样。调度器不能再假设“把任务从CPU迁到GPU就能加速”而必须知道“这个任务里哪一段必须用GPU哪一段必须用NPU哪一段只能用CPU”。内存访问非对称性CPU访问L3缓存延迟约30nsGPU访问显存延迟约100nsNPU访问片上SRAM延迟仅2ns。但三者共享同一套物理地址空间。传统MMU只管“虚拟地址→物理地址”不管“这个物理地址离哪个计算单元更近”。结果就是CPU刚把数据写进某块内存GPU要读它却因缓存一致性协议如ACE触发大量snoop traffic把本就不宽的片上总线打满。功耗响应异步性CPU频率可在微秒级动态升降GPU需要数百微秒预热NPU启动/关闭则需毫秒级电源门控。调度器若按CPU节奏发指令GPU可能还没升频到位就收到计算请求NPU则可能刚唤醒就因任务取消又关机——频繁启停比持续运行更耗电。提示别迷信“统一内存架构UMA”这个词。UMA只解决地址空间统一不解决访问延迟差异。真正关键的是“近数据计算Near-Data Computing”——把计算单元尽量靠近它最常访问的数据。超异构调度的第一步就是把“任务”拆解成“数据流图Dataflow Graph”每个节点标注所需计算单元类型、数据精度、内存亲和性memory affinity。2.2 超异构调度的三层架构硬件感知层、算子编排层、运行时协同层我们最终在车载项目里落地的方案是三层解耦架构。它不依赖特定厂商SDK而是基于Linux内核模块用户态运行时编译器插件构建。每层解决一个核心矛盾硬件感知层Kernel Space解决“芯片知道什么”。我们写了定制内核模块通过ACPI _HID表读取芯片的硬件拓扑描述类似PCIe AER但扩展了NPU/GPU的功耗域、内存带宽域、中断号映射。模块暴露/sys/class/hetero_topology/接口返回JSON格式拓扑{ cpu_cluster: {id: 0, cores: [0,1,2,3], l3_cache_size: 2MB}, gpu_cluster: {id: 1, compute_units: 16, memory_bandwidth: 128GB/s, shared_with_npu: true}, npu_cluster: {id: 2, tensor_cores: 8, sram_size: 16MB, latency_to_gpu: 5ns} }这个信息让上层知道GPU和NPU共享部分片上总线调度时要避免同时满载NPU的SRAM虽小但延迟极低适合放权重GPU显存带宽高适合放特征图。算子编排层User Space Compiler解决“任务知道怎么拆”。我们改造了TVM编译器在Relay IR阶段插入“异构切分Pass”。例如一个ResNet50的conv2d算子Pass会根据输入张量形状、精度要求FP16 vs INT8、以及硬件拓扑信息决定若输入channel 32 且 weight已量化为INT4 → 拆给NPU利用其INT4专用加速器若输入channel 256 且需FP16精度 → 拆给GPU利用其大带宽显存若需做BN归一化或ReLU → 留给CPU因其控制流密集 编译后生成的Graph JSON里每个算子节点带target: [cpu, gpu, npu]和memory_hint: sram|ddr字段。运行时协同层Runtime Library解决“执行时怎么协同”。这是最易被忽视也最关键的一层。我们没用OpenCL或Vulkan而是开发了轻量级Runtime核心是三个协同原语hetero_sync_fence()跨引擎同步点比传统fence更细粒度能指定“等待GPU完成DMA”还是“等待NPU释放SRAM锁”hetero_mem_alloc()分配时指定affinity_mask如HETERO_AFFINITY_NPU_SRAM | HETERO_AFFINITY_GPU_DDRRuntime自动选择最优物理地址hetero_task_submit()提交任务时传入priority_class实时/高优/普通和latency_budget_ms如目标检测必须50msRuntime据此动态调整各引擎的频率策略。这三层不是堆砌技术而是把“调度”从“抢时间片”变成“导数据流”。就像城市交通管制硬件感知层是测绘地图哪里有高架、哪里是单行道算子编排层是规划路线货车走高速、电动车走支路运行时协同层是红绿灯配时确保救护车到达路口时绿灯刚好亮起。2.3 关键指标定义别再只看“算力TOPS”要看“有效吞吐密度”很多团队用“芯片标称算力”选型结果交付时发现实际吞吐只有标称值的1/5。问题出在指标定义上。超异构芯片的有效性能必须用三个新指标衡量数据搬运效率Data Movement Efficiency, DME单位焦耳能量完成的有用计算量TOPS/W而非单纯算力。公式DME (模型FLOPs) / (总能耗 - 计算单元静态功耗)我们实测某芯片跑YOLOv5s标称16TOPS实测DME仅2.1TOPS/W。瓶颈在CPU-GPU间拷贝特征图每次拷贝消耗0.8W占总功耗35%。优化后用NPU直接处理原始图像减少中间特征图生成DME升至5.7TOPS/W。跨引擎协同延迟Cross-Engine Latency, CEL从一个引擎发出同步信号到另一个引擎开始执行的延迟。传统PCIe方案CEL10μs片上超异构设计目标100ns。我们用逻辑分析仪实测CPU调用hetero_sync_fence()后NPU在83ns内启动下一个算子——这得益于芯片内部专用的AXI Coherency Interconnect总线绕过了ARM SMMU的TLB刷新开销。内存带宽利用率均衡度Memory Bandwidth Utilization Balance, MBUB各计算单元对共享内存带宽的实际占用标准差/均值。理想值趋近0。MBUB0.4意味着带宽被某引擎独占如GPU疯狂刷显存其他引擎饿死。我们通过Runtime的带宽QoS策略将MBUB从0.62压到0.18整体吞吐提升2.3倍。这三个指标才是评估超异构调度效果的黄金标准。它们不依赖特定模型可复现、可测量、可优化。下次评审会别再问“NPU利用率多少”直接要DME和MBUB数据。3. 实操从零搭建超异构调度环境的完整链路3.1 硬件准备与拓扑探测别跳过这一步否则后面全是坑很多人想直接写调度逻辑却连芯片真实拓扑都没摸清。我建议严格按以下顺序操作每步都有避坑点第一步确认芯片是否真支持硬件级超异构协同不是所有“CPUGPUNPU”芯片都行。关键看三点是否有硬件一致性总线如ARM CHI、Synopsys NoC而非简单共享DDR控制器NPU/GPU是否支持硬件缓存一致性如ARM CCI-500而非靠软件cache flush是否提供跨引擎中断机制如ARM GICv4的Direct Injection而非全靠轮询。查证方法翻芯片手册的“System Architecture”章节搜索关键词“coherent interconnect”、“snoop control unit”、“cross-engine interrupt”。我们曾因忽略这点在某款国产芯片上折腾两周才发现其NPU与CPU内存完全隔离必须靠PCIe模拟DMA彻底放弃超异构调度。第二步用标准工具探测真实拓扑别信厂商宣传页。用Linux命令验证# 查看CPU拓扑确认大小核、集群 lscpu | grep -E (Socket|Core|Thread|NUMA) # 查看GPU确认是否被识别为独立设备 lspci -k | grep -A 3 -i vga # 查看NPU关键很多NPU伪装成PCIe设备 lspci -vv -s $(lspci | grep -i npu | awk {print $1}) | grep -A 10 Capabilities # 检查ACPI硬件描述超异构芯片必备 sudo cat /sys/firmware/acpi/tables/SPD0 | hexdump -C | head -20如果SPD0表存在说明芯片提供了标准化硬件描述。这是我们后续写内核模块的基础。第三步编译并加载硬件感知内核模块我们开源了基础模块hetero-topoGitHub可搜适配主流ARM64平台。编译要点必须启用CONFIG_ACPI_TABLE_UPDATESy否则读不到SPD0表模块中acpi_walk_namespace(ACPI_TYPE_DEVICE, ...)遍历所有设备匹配_HID为HET0001的节点内存分配用dma_alloc_coherent()确保CPU/NPU/GPU都能一致访问。加载后检查ls /sys/class/hetero_topology/ # 应看到 cpu0/ gpu0/ npu0/ 目录 cat /sys/class/hetero_topology/gpu0/bandwidth # 返回128000000000即128GB/s注意首次加载可能报Unknown symbol in module错误。这是因为acpi_dev_get_first_match_dev()等函数在较老内核5.10未导出。解决方案要么升级内核要么在模块中用acpi_get_child()替代牺牲一点健壮性。3.2 算子编排实战用TVM定制异构切分Pass我们以PyTorch模型转TVM为例展示如何让编译器自动拆分算子环境准备# 安装带异构支持的TVM需patch git clone https://github.com/apache/tvm.git cd tvm git checkout v0.13.0 # 应用我们的异构切分patchGitHub repo有链接 git apply hetero-pass.patch make -j$(nproc) sudo make install核心代码定义切分策略# hetero_partition.py from tvm import relay, runtime from tvm.relay import transform import json def hetero_partition(mod, params, target_hardware): 根据硬件拓扑信息自动切分算子 # 1. 读取硬件拓扑从/sys/class/hetero_topology/ with open(/sys/class/hetero_topology/topology.json) as f: topo json.load(f) # 2. 定义切分规则 def partition_func(expr): if isinstance(expr, relay.expr.Call): op_name expr.op.name if op_name nn.conv2d: # 根据输入尺寸和精度决策 if expr.attrs[data_layout] NHWC and \ expr.attrs[kernel_layout] OHWI and \ expr.attrs[out_dtype] int8: # 小卷积INT8 → NPU return npu elif expr.args[0].checked_type.shape[1] 256: # channel 256 # 大通道 → GPU return gpu else: # 其他 → CPU return cpu return cpu # 默认 # 3. 执行切分 mod transform.PartitionGraph( partition_funcpartition_func, # 关键指定内存hint memory_hintlambda node: sram if node.target npu else ddr )(mod) return mod # 使用示例 onnx_model onnx.load(yolov5s.onnx) mod, params relay.frontend.from_onnx(onnx_model) mod hetero_partition(mod, params, ascend910b)编译与部署# 生成异构可执行文件 with tvm.transform.PassContext(opt_level3): lib relay.build(mod, targetllvm, paramsparams) # 部署时Runtime自动识别target字段调用对应引擎 rt_mod tvm.contrib.graph_executor.GraphModule(lib[default](tvm.cpu())) rt_mod.set_input(**input_data) rt_mod.run()实测效果YOLOv5s在昇腾910B上编译后生成的Graph包含127个算子节点其中89个标记为npu23个gpu15个cpu。端到端延迟从纯CPU的142ms降至47msDME提升3.1倍。关键不是“快”而是“稳定”——延迟抖动从±15ms降到±2ms这对自动驾驶至关重要。3.3 运行时协同手写一个最小可行Runtime别被“Runtime”吓住。一个最小可行版本只需200行C代码。核心是三个函数内存分配hetero_mem_alloc.c#include linux/dma-mapping.h #include asm/cacheflush.h void* hetero_mem_alloc(size_t size, int affinity_mask) { struct device *dev; void *addr; // 根据affinity_mask选择设备 if (affinity_mask HETERO_AFFINITY_NPU_SRAM) { dev get_npu_device(); // 从platform bus获取NPU device addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); // 关键禁用CPU cache因NPU直接访问物理地址 set_memory_uncached((unsigned long)addr, size PAGE_SHIFT); } else if (affinity_mask HETERO_AFFINITY_GPU_DDR) { dev get_gpu_device(); addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); } else { // 默认CPU DDR addr kmalloc(size, GFP_KERNEL); } return addr; }跨引擎同步hetero_sync.c// 利用芯片内置的硬件同步寄存器非软件spinlock #define SYNC_REG_BASE 0x12340000 #define CPU_TO_NPU_SYNC 0x10 #define NPU_TO_GPU_SYNC 0x20 void hetero_sync_fence(int sync_id) { volatile uint32_t *reg (uint32_t*)ioremap(SYNC_REG_BASE, 4096); switch(sync_id) { case CPU_TO_NPU: // CPU写1到同步寄存器 writel(1, reg CPU_TO_NPU_SYNC); // 等待NPU回写0表示已启动 while(readl(reg CPU_TO_NPU_SYNC) ! 0); break; case NPU_TO_GPU: // 同理... break; } }任务提交hetero_task.cstruct hetero_task { void (*func)(void*); void *args; int target; // TARGET_CPU/TARGET_GPU/TARGET_NPU int priority; int latency_budget_ms; }; int hetero_task_submit(struct hetero_task *task) { // 根据target调用不同引擎的submit函数 if (task-target TARGET_NPU) { return npu_submit(task-func, task-args, task-priority); } else if (task-target TARGET_GPU) { return gpu_submit(task-func, task-args, task-latency_budget_ms); } else { return cpu_submit(task-func, task-args); } }编译成内核模块加载后用户态程序即可调用// 用户态测试 void my_npu_kernel(void *data) { // NPU汇编代码或驱动API调用 } int main() { void *buf hetero_mem_alloc(1024*1024, HETERO_AFFINITY_NPU_SRAM); struct hetero_task task { .func my_npu_kernel, .args buf, .target TARGET_NPU, .latency_budget_ms 10 }; hetero_task_submit(task); hetero_sync_fence(CPU_TO_NPU); printf(NPU task done!\n); }这套最小Runtime已在我们三个项目中稳定运行超18个月。它不追求功能完备但确保了“数据在哪、算在哪、同步在哪”三件事绝对可控。记住超异构调度的起点永远是让硬件能力可编程、可测量、可验证。4. 常见问题与排查技巧实录那些文档里不会写的坑4.1 “NPU利用率100%但整体吞吐没提升”——内存带宽墙的真实案例现象用perf监控发现NPU计算单元忙闲比98%但模型端到端延迟毫无改善甚至波动更大。排查路径先排除CPU瓶颈top -H看是否有CPU线程100%若有说明NPU结果回传太慢CPU在等查内存带宽sudo cat /sys/class/hetero_topology/npu0/bandwidth_used需内核模块支持若接近标称值说明带宽饱和抓取内存访问trace用perf record -e mem-loads,mem-stores -a sleep 10然后perf report --sort comm,dso看哪个进程占DDR带宽最多。根因与解法我们遇到的真实案例是NPU输出INT8特征图但后续CPU做后处理NMS需要FP32驱动自动做了INT8→FP32转换每次转换触发一次DDR读写。1MB特征图转换耗时12ms占总延迟30%。解法在NPU输出阶段直接用FP16精度NPU支持避免转换或修改驱动让NPU输出时附带scale参数CPU用定点运算做NMS省去浮点转换。实操心得NPU的“高利用率”往往是假象。真正的瓶颈常在它和CPU/GPU之间的“数据走廊”。永远先测带宽再谈算力。4.2 “GPU和NPU一起跑就死机”——缓存一致性失效的隐蔽陷阱现象单独跑GPU或NPU都正常两者并发时系统偶发paniclog显示Unable to handle kernel NULL pointer dereference。根因芯片手册里写着“支持ACE一致性”但实际只在CPU-GPU间启用NPU走的是另一套AXI总线且其cache controller未接入SMMU。结果CPU写完数据给GPUGPU看到最新值但NPU从同一地址读却读到旧值cache dirty导致计算错误。诊断方法关闭所有cacheecho 3 /proc/sys/vm/drop_caches若问题消失基本确定是cache问题用armclang --cache-disable编译NPU固件验证是否仍崩溃。终极解法硬件层联系芯片厂确认NPU是否支持AXI Cache Attribute设置强制设为Cacheable, Write-Through软件层在NPU启动前对共享内存区域执行__builtin_arm_dccmvac()clean cache启动后执行__builtin_arm_dccimvac()invalidate cache架构层改用“零拷贝”设计——NPU输出直接写入GPU的显存地址通过IOMMU映射绕过CPU DDR。我们最终采用方案3用drm_gem_object_lookup()获取GPU buffer的DMA地址传给NPU firmware。系统稳定性从99.2%升至99.999%。4.3 “调度延迟忽高忽低”——电源管理策略的反直觉影响现象相同模型白天跑延迟稳定在45ms晚上突然跳到80ms且伴随CPU温度下降5℃。真相Linux内核的cpufreqgovernor如ondemand在负载低时降频但超异构调度器误判为“CPU空闲”把更多任务压给GPU/NPU导致它们因缺乏CPU协同而排队。更糟的是GPU/NPU的电源管理如nvidia-smi -q -d POWER在无CPU指令时进入深度睡眠唤醒延迟高达3ms。解决方案统一电源策略在/etc/default/grub中添加intel_idle.max_cstate1Intel或arm_pm_cpu_pwr_off0ARM禁用深度睡眠固定频率echo performance /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor关键在Runtime中加入“协同唤醒”逻辑——当NPU任务提交时自动触发CPU频率提升并保持100ms窗口。血泪提醒超异构调度不是孤立的算法它必须与电源管理、热管理、内存管理深度耦合。任何单点优化都可能引发连锁反应。我们曾为优化NPU延迟关闭了CPU thermal throttling结果GPU因散热不足降频整体性能反而下降。务必做全栈压力测试。4.4 调度器选型速查表什么场景该用什么方案场景推荐方案关键理由实测数据边缘设备功耗敏感模型固定自研轻量Runtime TVM切分无OS开销内存占用2MB启动延迟50ms某安防摄像头功耗降低37%续航2.1h云服务器多租户模型动态Kubernetes Device Plugin KubeEdge利用K8s原生调度Device Plugin暴露NPU/GPU为资源KubeEdge支持边缘协同某AI云平台资源碎片率从42%降至8%实时系统自动驾驶10ms硬实时RT-Linux 自研调度器抢占式内核中断延迟1μs可绑定CPU core隔离NPU/GPU中断某L4车队任务抖动从±8ms降至±0.3ms移动端APP集成快速迭代Android NNAPI Vendor HAL复用Android生态Vendor HAL由芯片厂提供APP无需关心底层某手机厂商AR滤镜帧率从22fps升至48fps没有银弹方案。选型的核心原则是让调度器复杂度匹配业务确定性。业务越确定如固定模型、固定功耗预算越该用轻量方案业务越不确定如多模型混部、SLA多变越该借力成熟生态。我们曾强行在移动端用自研调度器结果因Android SELinux策略冲突调试耗时3周——后来换成NNAPI2天搞定。5. 调度之外超异构芯片的“隐藏成本”与长期演进5.1 工程师的隐形负担调试复杂度指数级上升当CPU、GPU、NPU共存于一颗芯片调试不再是“单点故障排查”而是“三维故障定位”。举个真实例子某次模型精度下降0.3%我们花了72小时才定位到根因——第一层模型输出异常 → 检查NPU固件版本OK第二层NPU输入数据异常 → 抓取DMA传输buffer发现数据错位第三层DMA错位 → 检查CPU内存分配发现kmalloc返回地址未对齐第四层地址不对齐 → 检查CPU cache line sizeARM64默认64B但NPU DMA引擎要求128B对齐第五层最终发现芯片手册第387页脚注写着“NPU DMA引擎要求128B对齐此要求不适用于CPU发起的DMA仅适用于NPU主动读取”。这种嵌套式问题在单架构芯片里几乎不存在。它带来的隐性成本是知识栈爆炸工程师需同时懂ARM汇编、GPU shader ISA、NPU张量指令集工具链割裂CPU用GDBGPU用NsightNPU用厂商私有工具日志格式不兼容复现困难问题常在特定数据分布、特定温度、特定电压下触发实验室难复现。我们的应对策略是建立“三维调试矩阵”——横轴CPU/GPU/NPU纵轴硬件/驱动/应用深轴时间戳对齐。所有日志强制打UTC时间戳用ptp4l同步各引擎时钟再用Python脚本关联分析。这套流程让平均故障定位时间从42小时降至9小时。5.2 未来三年超异构调度的三大演进方向基于我们参与的6个芯片项目判断三个确定性趋势第一调度权从OS向编译器下沉现在调度逻辑在Runtime或OS层但未来会前移到编译期。LLVM/MLIR已支持target属性TVM正开发hetero-schedulePass。好处是编译时就知道数据流路径可做全局优化如把CPU的BN融合进NPU的conv算子。我们预研的MLIR方案编译时自动插入prefetch指令到NPU SRAM实测减少30%等待延迟。第二内存架构从“共享”走向“分级协同”下一代芯片不再追求“统一内存”而是“分级内存协同”。例如CPU用LPDDR5X高容量GPU用HBM3高带宽NPU用3D堆叠SRAM超低延迟。调度器必须理解“数据该放在哪一级”而不仅是“放在哪块内存”。我们已在某芯片上验证把Transformer的attention权重常驻NPU SRAMKV缓存放GPU HBM输入序列放CPU DDR整体吞吐提升2.8倍。第三安全隔离从“进程级”升级为“算子级”当前容器隔离只能防进程越界但超异构芯片里一个恶意算子可能通过NPU的DMA引擎篡改GPU显存。解决方案是在硬件层增加“算子沙箱”如ARM Morello的Capability调度器提交任务时必须附带capability token指定该算子允许访问的内存范围、允许调用的引擎、允许的精度模式。这已是RISC-V PMP和ARM SME的标配方向。最后分享一个个人体会超异构不是终点而是计算范式的“临界点”。当一颗芯片能同时高效运行控制流CPU、数据流GPU、张量流NPU我们终将告别“为任务选硬件”迎来“为硬件写任务”的时代。而调度就是这场变革的操作系统。你不必成为芯片专家但必须理解每一行调度代码都在重新定义“计算”本身。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →