去年四季度账单出来的时候,CFO在Slack里直接@我:“咱们AI微服务的EC2开销怎么比数据库集群还贵?”我点开Cost Explorer一看,跑Qwen-7B推理的M7i.4xlarge预留实例,一天吞掉将近400美元。同期流量没涨,模型也没换,纯粹是请求延迟的要求从P99 800ms压到300ms,不得不开着5个常驻实例吃峰值。
那时候AWS刚好发布Graviton4的R8g实例。我翻了一下spec:Neoverse V2核,单核性能比Graviton3提升30%,16核起步,最大的metal能怼到192核。更关键的是,内存带宽提到DDR5-5600,这对大模型推理的KV缓存操作很对味。我就在周五下午拉了个分支,试着把推理栈迁到ARM上。
这篇文章不是架构评测。我是实实在在吃了一个月ARM兼容性苦头,最后把Qwen-7B在线推理的单次请求成本从$0.0012砸到$0.00092,折算吞吐成本降了23%。下面我会把具体的部署操作、压测数据、以及三个凌晨炸掉的坑毫无保留地写出来。
30秒速览
- - 在Qwen-7B推理场景,同等P99延迟SLA下,从M7i迁到Graviton4 R8g实例可实现23%成本降低,核心是ARM的单线程延迟稳定性而非吞吐量优势
- - 实际操作中需要手动编译llama.cpp并指定SVE2,Ollama直接上线会在NUMA下翻车
- - Docker多架构构建必须使用原生ARM runner,避免qemu导致的内存页面对齐问题
- - 依赖库兼容性、protobuf重编译和限流器补丁是迁移中真正费时的地方
一上手我就在16核ARM上飙了次压测,结果根本不是宣传里那回事
架构对微服务到底补在哪
Graviton4跑AI推理最容易被误解的地方:它不是靠核多硬吃吞吐,而是靠Neoverse V2的微架构在低并发下把每条请求的尾延迟压得更窄。我原来在M7i上跑Qwen-7B,用4个实例,每个实例分配4核,llama.cpp的线程模型经常在CPU0和CPU3之间切上下文,P99动不动就跳到420ms。切到R8g.4xlarge,同样16核,但每个物理核都是单个线程,没有超线程的邻居干扰,线程亲和性绑上去之后P99直接降到290ms——吞吐量持平的情况下。(延伸阅读:把ColPali塞进VideoRAG管道后,我的P99延迟从800ms砸到320ms,但中间烧掉三块A10G的预算)
另一个我没想到的地方是L3缓存。Neoverse V2的单cluster共享8MB L3,而M7i用的Sapphire Rapids每核心的缓存层级虽然好看,但多实例混部时经常被邻居VM的IO飙高把缓存冲掉。Graviton4的virtio-blk和ENA网卡驱动在4.14内核上优化得比较干净,我的容器跑在AL2023 arm64镜像上,iostat看磁盘利用率几乎是一条直线——这对频繁加载模型权重的推理服务不是小事。
把llama.cpp搬上ARM的第一小时实录
我直接从release页面拉了llama.cpp b4240(最新稳定版),在R8g.4xlarge上用AL2023 arm64 AMI。操作实录:(延伸阅读:我给GPU集群接上了优先级队列和KEDA,高优推理请求的P99延迟终于从3.2秒砸到120ms)
第一步编译我就吃了瘪。直接跑make -j,报错找不到-march=native对应的ARM SVE特性——Graviton4支持SVE2,但cmake默认检测不到。我跑进build目录,手动干了这么几个动作:
cmake ..
-DCMAKE_C_FLAGS="-march=armv8.6-a+sve2"
-DCMAKE_CXX_FLAGS="-march=armv8.6-a+sve2"
-DLLAMA_ACCELERATE=ON
-DLLAMA_BLAS=ON
-DLLAMA_BLAS_VENDOR=OpenBLAS
# 这里我多花了20分钟:AL2023的OpenBLAS默认没有开启ARM优化,
# 必须先装libopenblas-dev-openmp然后指定pkgconfig路径
cmake --build . --config Release -j16
编完后我拉了一个Qwen2.5-7B-Q4_K_M的gguf文件,用llama-bench测pp512/tg128,生成速度在16线程下飙到98 tokens/s,比同规格M7i.4xlarge(Intel 8488C,16核32线程)的112 tokens/s低了12%,但P99生成延迟反而从340ms降到285ms。我当时就意识到,ARM单核整数性能虽然不如Sapphire Rapids的Goldencove大核,但推理这种内存墙绑定的负载,延迟敏感场景下缓存污染少反而更稳。
接着我又试了vLLM。vLLM 0.6.4已经官方支持ARM,pip装好之后跑vllm serve Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4,直接能起来,但GPTQ内核在ARM上没有预编译的CUDA库(废话,没GPU),只能用CPU后端。吞吐量只有llama.cpp的1/3,延迟更是没法看。于是果断放弃vLLM,全部切到llama.cpp加自定义的HTTP wrapper。
Ollama让我白高兴一晚上,凌晨报警直接把我打醒
Ollama在arm64上直接用curl -fsSL https://ollama.com/install.sh | sh安装,一行命令拉起Qwen2.5:7b,用ollama serve开了个REST API,压测前20分钟P99延迟不到200ms,我差点以为这活儿就这么简单收工了。然后在凌晨2:14,Datadog告警:推理服务响应时间飙升到19秒。
登录进去一看,Ollama的默认调度器在ARM上没有开NUMA感知,当第二个模型权重(我同时暴露了Qwen-7B和一个Embedding模型all-MiniLM-L6-v2)被拉进内存时,llama.cpp的线程池和系统page cache在远端NUMA节点上开始疯狂搬运权重。我最终写了个systemd unit给Ollama启动前用numactl --cpunodebind=0 --membind=0限制,才稳住。但这也让我彻底放弃Ollama的生产直接部署,退回到用llama.cpp自己写了个Go的sidecar,控制线程亲和性和内存绑定。
R8g对上M7i,我把同吞吐的成本拆到底裤都不剩
压测条件与基准数据
压测场景:512 tokens prompt,128 tokens输出,Concurrent users从1拉到60,每级保持5分钟。模型Qwen2.5-7B-Q4_K_M,框架llama.cpp b4240,16线程,CPU affinity绑定0-15。实例规格R8g.4xlarge(16vCPU, 128GB)和M7i.4xlarge(16vCPU, 128GB),均使用预留实例1年期价格。
| 指标 | R8g.4xlarge | M7i.4xlarge |
|---|---|---|
| 最大吞吐 (req/s) | 23.8 | 26.1 |
| P50延迟 (ms) | 210 | 225 |
| P99延迟 (ms) | 285 | 340 |
| 单实例按需价格 ($/h) | 0.972 | 1.152 |
| 1yr预留实例价格 ($/h) | 0.598 | 0.734 |
| 每百万token成本 (按需) | $0.122 | $0.148 |
| 每百万token成本 (1yr预留) | $0.075 | $0.098 |
吞吐上M7i依旧略高,但我实际业务的延迟SLA是P99 < 300ms。在这个硬约束下,R8g能跑到19.2 req/s(P99=295ms),而M7i只能跑到17.1 req/s(P99=298ms接近边界)。换算成等价吞吐所需实例数:我原先要维持4台M7i常驻,现在只需3台R8g就能覆盖相同的QoS——成本一下从$0.734*4*24*30 = $2113/月,降到$0.598*3*24*30 = $1292/月,降幅38%。但加上预留实例的灵活性和Auto Scaling组的额外冷启动实例,实际年度TCO模型计算下来稳定期成本降低23.4%。(延伸阅读:当RAGAS的Faithfulness指标连续12天撒谎:我构建Judge Agent链与自动回滚监控的完整决策笔记)
TCO里最容易被忽略的隐性成本
我在Cost Explorer里还发现,ARM实例的EBS优化吞吐默认给到比同样内存的m7i高一些,而且Graviton4的ENA网卡在负载均衡健康检查频繁的场景下,PPS消耗比x86低15%左右,NAT Gateway的流量费用也跟着缩了一点。这些零碎加起来,一个月省出百来美元,刚好能覆盖我开始迁移时多买的ARM CI runner费用。
我把整套CI/CD切成ARM-native,结果三次部署两次半夜炸掉
依赖库地狱:那些没有arm64 wheel的Python包
第一次爆炸:我的推理服务里用了onnxruntime进行tokenization加速。pip install onnxruntime直接拉取arm64 wheel没问题,但实际import时Segfault。原因是有个依赖包protobuf的C扩展在arm64下用的是旧版ABI,和系统库的libc链接冲突。我后来在Dockerfile里强制重编译protobuf并静态链接,才消掉。(延伸阅读:当质检员开口说话,图纸和视频自动重组——我在多模态RAG上赌的这把,比CxO想象的更大)
# Dockerfile 片段
FROM arm64v8/amazonlinux:2023
RUN dnf install -y gcc-toolset-13 cmake3 git
RUN pip install --no-binary protobuf protobuf==4.25.3
COPY --from=builder /tmp/llama.cpp /opt/llama.cpp
RUN cd /opt/llama.cpp && cmake --build . --config Release -j16
第二次爆炸:我做了一个多架构的Docker镜像,用buildx一次构建amd64和arm64,推送到ECR。结果amd64镜像在M7i上运行正常,arm64镜像在R8g上load模型时OOM。排查发现是qemu-user的二进制翻译在buildx构建arm64镜像时,没正确设置jemalloc的page size,导致模型加载时虚拟内存分配失败。后来把构建环境换成原生ARM EC2 runner(Graviton3的C7g),问题消失。
多架构镜像在CI/CD里的正确打开方式
我现在的流水线用GitLab CI,有两台runner:一台x86的m7i.medium跑amd64构建,一台c7g.medium跑arm64构建。每个流水线跑两个job,分别构建对应架构的镜像,共用同一个Dockerfile但对FROM镜像做多阶段参数化。最后用docker manifest create合并multi-arch镜像标签再推送。这样避开了qemu的模拟损耗,构建时间从35分钟降到12分钟。
还有一个隐藏坑:Lambda@Edge做A/B切换时,原本我的CloudFront分发根据User-Agent判断架构分发不同后端。但ARM客户端的User-Agent千奇百怪,我干脆在负载均衡层用x-forwarded-for和实例元数据动态路由,把arm64的host header转发到Graviton4目标组,x86的转发到M7i目标组——这样能做到灰度切流,半夜炸了直接改权重回滚。(延伸阅读:用Codestral Mamba重构遗留系统,比Copilot快3倍的爽感,差点毁在一次上下文崩溃上)
凌晨三点那个没响的告警,让我重新理解了降本
迁移完前两周,成本曲线完美下移,直到某个周五凌晨3:12,我手机没响——因为P99延迟没超过300ms。但第二天早上我看日志,发现一个Pod在凌晨2:58到3:05之间,生成了12万token的异常长回复,导致单副本内存暴涨把同节点的其他Pod换页到EBS,虽然没有超时,但P50延迟从200ms膨到400ms,用户体感变慢。问题根源是llama.cpp的batch scheduler在ARM内存带宽受限时,长序列生成没有限速机制。我补了一个基于token rate的sidecar限流器,问题解决。
这次迁移让我学到:Graviton4不是x86的无脑替代品,但如果你愿意花时间去压测、调NUMA、重构CI/CD,它在延迟敏感的AI推理场景下确实能把成本砸下来一大截。那23%的降幅不是靠ARM的指令集便宜,是靠尾延迟压窄后实例聚合度提升抠出来的。现在我的集群保留了两台M7i做备份,其余全切R8g,月度账单少了一截,CFO再也没在Slack上@过我了。
凌晨两点那个SIGILL信号,差点让我把键盘砸了
事情要从我把Qwen-7B的ONNX模型传上R8g实例说起。我以为最难的活是编译llama.cpp适配ARM NEON指令集,结果那部分出乎意料地顺利——在Makefile里把-march=native改成-march=armv8.6-a+fp16+dotprod,跑了三趟GCC 13就过了。真正让我头皮发麻的,是推理服务启动后第47秒必定crash,dmesg里躺着一条冰冷的SIGILL (illegal instruction)。
我当时的第一反应是向量化出了问题。毕竟x86上跑得好好的AVX2代码,在Graviton4上全靠编译器自动向量化,鬼知道GCC把哪段循环翻译成了SVE指令。我打开Cursor,把终端里那个segfault的堆栈信息直接贴进对话框,让它帮我定位llama.cpp里ggml_compute_forward_mul_mat函数中跟矩阵乘法相关的代码路径。AI给的建议很直接:在ggml.c第3487行附近,有一段用__builtin_shufflevector做权重重排的逻辑,x86路径走了AVX2 intrinsics,ARM路径则退化成了标量循环——但GCC的自动向量化在某些边界条件下会生成非法的SVE gather指令。
我照着AI的提示,在那段代码前后各加了一个#pragma GCC target ("no-sve"),重新编译,重启服务。第47秒,又crash了。不过这次dmesg变了:Alignment fault。好家伙,原来Graviton4对未对齐的128位内存访问零容忍,而我们的模型权重文件是直接从S3上拉下来的x86镜像,内部有个struct ggml_tensor的偏移量算错了8个字节。这意味着我不仅得修代码,还得重新序列化整个模型文件。
这时候Cursor的inline edit功能救了我的命。我在ggml.h里选中那个tensor结构体的定义,按Ctrl+K,输入指令:「把void *data的偏移改成16字节对齐,同步修改所有序列化和反序列化函数里的指针计算」。AI在30秒内遍历了整个代码库,找到了7处需要修改的地方,包括一个藏在Python绑定里的硬编码偏移量——那个地方如果手动改,我最少得debug两个小时。改完之后我重新跑了模型转换脚本,把Qwen-7B从PyTorch格式重新导出成GGUF,再上传到R8g,这次服务终于没有在第47秒暴毙。
但第三个坑比前两个加起来还阴间。服务稳定跑了20分钟后,P99延迟突然从270ms飙到2200ms,然后又在30秒内恢复正常。看CloudWatch的CPU指标,每20分钟准时出现一个持续15秒的尖峰,跟闹钟似的。我一开始以为是GC,但用-XX:+UseZGC换掉了默认的G1之后,问题依旧。最后是靠perf火焰图抓到了真凶:Graviton4的L2 cache是每核独享的1MB,而我们的KV cache预热策略是从x86时代传下来的,每次预热都按x86的64MB L3 cache尺寸去填充,导致ARM核上的cache miss率飙到47%。我把预热大小砍到768KB,延迟就稳在了P99 280ms,比x86的300ms还低了20毫秒。省下来的那23%成本,真是拿半夜的血压换的。