2GB 内存跑深度学习模型?在 99 元服务器上的部署实战

背景

今年买了阿里云 99 元/年的新老同享 ECS,想在上面部署 SonoX — 一个基于 Switch 半监督学习框架的超声影像病灶分割与分类平台。

服务器配置如下:

项目规格
实例型号ecs.e-c1m1.large
CPU2 vCPU(物理单核超线程)
内存2 GB
公网带宽3 Mbps (出) / 200 Mbps (入)
系统Debian 13.5
费用99 元/年

项目本身的资源需求:

组件规模
分割模型 (UNet)4 个数据集 × 6 个半监督变体 + baseline = 28 个,每个 ~7MB
分类模型ViT-Base ~328MB + 融合头 ~15MB + Radiomics ~6MB
模型权重总大小~540MB
后端代码单文件 ~770 行 FastAPI

核心矛盾:~540MB 模型权重 + PyTorch 运行时,要塞进 2GB 内存的服务器里,还要留余地给推理计算。

下面记录优化过程中踩过的坑和实际有效的措施。

选 Debian 而不是 Ubuntu

同一台机器,Ubuntu Server 基础安装就要吃掉 ~400MB(snapd、networkd 等),而 Debian 最小化安装只需 ~128MB。二者都用 apt,二进制兼容,迁移成本几乎为零。

UbuntuDebian
基础内存占用~400MB~128MB
Snap 守护进程有 (~50MB)
默认服务

在 2GB 服务器上,Debian 比 Ubuntu 省出约 250MB — 对一个内存紧张的深度学习服务来说,这就是能跑和不能跑的区别。

关闭 crashkernel,回收「公摊内存」

开机 free -h 一看,标称 2GB 的服务器实际可用只有约 1.65GB。少掉的 ~350MB 主要来自两块:内核自身开销 + crashkernel 预留。 对个人项目来说,服务器真 crash 了直接重启即可,不需要 kdump。

1
2
3
4
5
6
7
# 查看 kdump 占用
sudo kdump-config show

# 编辑 /etc/default/grub,删除 crashkernel=... 那段
# 然后更新 grub 并重启
sudo update-grub
sudo reboot

重启后可用内存涨到 ~1.8GB,找回 100MB+。

创建 Swap:最后的保险丝

上面的优化都是在物理内存上腾挪。但模型推理是尖刺负载——平时风平浪静,一次分类请求可能瞬间多占几百 MB。2GB 物理内存没有容错空间,一旦超了就 OOM Kill。

加一个 2GB 的 Swap 文件作为缓冲:

1
2
3
4
5
6
7
8
# 创建 2GB Swap 文件
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# 写入 /etc/fstab 实现开机自动挂载
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

验证:

1
2
3
4
free -h
#               total        used        free      shared  buff/cache   available
# Mem:           1.6Gi       451Mi       627Mi       2.5Mi       685Mi       1.1Gi
# Swap:          2.0Gi          0B       2.0Gi

但阿里云的磁盘性能本来就很垃圾,开启 Swap 纯属心理安慰聊胜于无吧。

模型加载策略:懒加载 + 用完即释放

28 个分割模型如果全部预加载,内存直接爆炸。核心策略:

分割模型:按数据集按需加载。用户请求 lymph-node 时才加载该数据集下的 6 个模型,处理完后根据配置决定是否卸载。

分类模型:更大的 ViT (328MB) 采用懒加载 — 第一次分类请求到达时才加载,使用完毕后立即释放:

1
2
3
4
5
6
7
def classify(image, mask):
    _ensure_cls_models()      # 首次调用才加载
    try:
        # ... 推理 ...
    finally:
        if not KEEP_CLS_MODELS_LOADED:
            unload_cls_models()  # 用完释放

实际运行中,平时内存占用约 500MB(仅常驻一个数据集的分割模型),峰值不超过 1.4GB。比全量预加载节省约 300-400MB。

CPU 动态量化:328MB ViT 降到 ~85MB

在无 GPU 的 CPU 服务器上跑 ViT-Base 推理是一件奢侈的事。PyTorch 的动态量化可以把模型中的 nn.Linear 权重从 float32 压缩到 int8,内存占用减少约 75%,推理速度反而提升

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import torch

def _maybe_quantize_dynamic(module, module_name):
    engine = "fbgemm"  # x86 CPU 最佳
    torch.backends.quantized.engine = engine
    module = torch.quantization.quantize_dynamic(
        module, {torch.nn.Linear}, dtype=torch.qint8
    )
    return module

# 对 ViT、RadiomicsNN、ClassificationHead 三个模型都做量化
cls_model_vit = _maybe_quantize_dynamic(cls_model_vit, "classification ViT")

配合 SONOX_CPU_THREADS=1(限制 PyTorch 线程数避免争抢),在 2 vCPU 上单次分类推理约 3-5 秒,内存峰值可控。

磁盘缓存:避免重复推理

SonoX 的 API 按原始文件名标识同一张图片。如果用户反复查看同一张图的 segmentation/classification 结果,完全没有必要重新跑一遍推理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
CACHE_DIR = Path("cache")

def _cache_path(dataset, filename):
    return CACHE_DIR / dataset / f"{filename}.json"

def _read_cache(dataset, filename):
    # 命中则直接返回 JSON,跳过全部模型推理
    ...

def _write_cache(dataset, filename, data):
    # 推理完成后写入缓存
    ...

async def api_segment(image, dataset, filename):
    if filename:
        cached = _read_cache(dataset, filename)
        if cached and "segment" in cached:
            return cached["segment"]  # 直接返回,0 计算
    # ... 否则正常推理并写缓存

缓存文件存于 cache/ 目录,JSON 格式,一条记录约 200KB(含 base64 图片 overlay)。使用 run.sh 重启服务时自动清理。

开发环境优化

uv 镜像源

国内服务器拉 PyPI 慢到怀疑人生。在 pyproject.toml 中直接配置阿里云镜像:

1
2
3
4
[[tool.uv.index]]
name = "aliyun"
url = "https://mirrors.aliyun.com/pypi/simple/"
default = true  # 很重要

SSH 代理转发拉 GitHub 代码

从国内服务器 git clone GitHub 经常 TLS 超时。最稳定方案:SSH 远程端口转发 + Git 代理

本地 ~/.ssh/config

1
2
3
4
Host aliyun
  HostName <公网IP>
  User root
  RemoteForward 7890 127.0.0.1:7890

服务器端:

1
2
3
4
5
6
# 确认转发端口在监听
ss -tlnp | grep 7890

# 配置 Git 走本地代理
git config --global http.proxy http://127.0.0.1:7890
git config --global https.proxy http://127.0.0.1:7890

如果转发失败,检查 /etc/ssh/sshd_config 中是否开启了 AllowTcpForwarding yes

1
2
3
AllowAgentForwarding yes
AllowTcpForwarding yes
GatewayPorts yes

更省事的方案:配置 SSH Key 后用 git clone git@github.com:...,完全绕过代理。

Git 信息持久化

1
2
3
git config --global user.name "xxx"
git config --global user.email "xxx"
git config --global credential.helper store

zsh + 插件

1
2
3
4
5
apt install -y build-essential vim curl wget git tmux zsh tree lsof
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
# .zshrc 中:plugins=(git zsh-autosuggestions zsh-syntax-highlighting)

效果总结

指标优化前优化后
系统可用内存~1.65 GB~1.80 GB
系统基础占用~400MB (Ubuntu)~128MB (Debian)
模型常驻内存~500MB (全量预加载)~150MB (按需加载)
分类模型内存~350MB (FP32)~100MB (INT8)
空闲内存占用Overflow~80%
重复请求耗时3-5s<10ms (缓存命中)

最终在 99 元/年的机器上跑起了一个包含 28 个分割模型 + 3 个分类模型的超声影像分析服务,内存有余量,响应可接受。

核心思路:省着用(懒加载)、压着用(量化)、别重复(缓存)

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy