Liquid AI

LiquidAIはTransfomerベースのQwenなどより、エッジ・オンデバイス用途(モバイル/タブレット/NPU)に最適化された、SLMとして、LFM2というモデルをHuggingfaceなどに公開しています。
今回は、LFM2-350Mと比較したかったので、Qwen2.5-0.5Bと比較しました。
まずGPTに机上での比較表を作成してもらいました。
| 項目 | LFM2-350M | Qwen2.5-0.5B |
|---|---|---|
| パラメータ数 | 約 350 M パラメータ(0.35B) | 約 500 M パラメータ(0.5B)/“0.5B”サイズと記載 |
| アーキテクチャ | 「ハイブリッド構造」:公式に「new hybrid architecture … faster decode & prefill performance than Qwen3 on CPU」 と記述あり → “畳み込み+ゲート+少数Attentionブロック” という記述あり(モデルカードには詳細ブロック構成記載なし) |
Transformer Decoder 型がベース。モデルカードには Transformer (SwiGLU, Attention QKV bias, group query attention) 等の記載あり |
| 最大コンテキスト長/入力長 | モデルカードに明確なトークン長記載なし。宣伝文では「on-device deployment」「optimized for edge AI」など言及あり | ドキュメントに「pre-training packed sequence length up to 32,768 tokens; maybe extended to 131,072 tokens for some models」「assistant message maximum ~16,384 tokens for instruct models」など記載あり |
| 量子化・軽量化対応 | GGUF形式(4bit/8bit)など、エッジ向け軽量展開用に準備あり | 量子化(例:INT8)版あり。モデルカードに “optimized weights … INT8” の記録あり |
| 主な用途・設計目的 | エッジデバイス/オンデバイス生成をターゲット。「fastest on-device gen-AI experience」など宣伝文あり | 汎用大規模生成・長文・多言語・コード対応。クラウド/サーバ用途も視野に入れた設計 |
| モデルシリーズ・サイズ展開 | LFM2シリーズ:350M, 700M, 1.2B などの軽量モデル展開あり | Qwen2.5シリーズ:0.5B, 1.5B, 3B, 7B, 14B, 32B, 72B のサイズ展開あり |
| ライセンス/公開状況 | モデルカード上「open-weights」等の記述あり(詳細ライセンスは要確認) | Apache-2.0 などオープンライセンス記載あり |
| 特筆性能・宣伝文 | 「2× faster decode and prefill performance than Qwen3 on CPU」 | 「Packed sequence length, structured output, instruction following improved」など記載あり |
検証環境
M1 Macのjupyter notebookでcpu実行して、メモリをどのくらい使うか見ていきます。
Linux/macOSでは、プロセスが利用するメモリは下記などが混在し、複数の指標で見る必要があるとのことです。
- 物理RAM
- ページキャッシュ(ファイルをmmapした部分)
- スワップ(RAMから退避された部分)
各指標の意味
| 指標 | 正式名 / 意味 | 含まれるもの | 含まれないもの | 典型的な利用目的 |
|---|---|---|---|---|
|
RSS (Resident Set Size) |
プロセスが 実際にRAM上に載せているページの合計量 | コード・データ・スタック・共有ライブラリなど、現時点で物理RAMに存在するページ | スワップに退避中のページ、他プロセス共有のうち未ロード部分 | 「この瞬間RAMをどの程度占有しているか」 → メモリ負荷の瞬間的な指標 |
|
USS (Unique Set Size) |
そのプロセス専有のRAM量(他と共有していない部分のみ) | 自プロセス固有のヒープ・スタック・一時領域など | 共有ライブラリやメモリマップなど共有ページ |
プロセス単体が消費している純粋メモリ量 →「このプロセスを kill したら解放される量」 |
| Swap Usage | スワップ(ディスク)上に退避されたページ量 | RAMから追い出されたページ | RAM上のデータ(実メモリ) |
メモリ逼迫によるI/O遅延の兆候 → 高いと推論が遅くなる・GCが頻発する |
|
Mapped Pages (File-backed memory, mmap) |
ファイルをメモリマップした領域のサイズ | モデル重み(.safetensors / .gguf)の mmap部分 |
実行時ヒープやテンポラリ |
大規模モデル特有の“読み込み済みがまだ使われてない領域”。 キャッシュ扱いなのでRSSには出ないことがある。 |
検証スクリプト(jupyter notebook)
!pip install -U torch transformers accelerate datasets evaluate sacrebleu rouge-score psutil matplotlib pynvml
import sys, platform, torch, datetime, os
print("Python:", sys.version.split()[0])
print("PyTorch:", torch.__version__)
print("CUDA available:", torch.cuda.is_available())
print("Device name:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")
print("Started at:", datetime.datetime.now())
import matplotlib.pyplot as plt
Python: 3.10.5
PyTorch: 2.9.0
CUDA available: False
Device name: CPU
Started at: 2025-10-31 16:47:35.165621
LFM_MODEL_NAME = "LiquidAI/LFM2-350M"
QWEN_MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
DTYPE = "float32"
COMPUTE_DEVICE = "cpu"
MAX_NEW_TOKENS = 256
WARMUP_NEW_TOKENS = 8
PROMPT_LENGTHS = [512, 2048, 8192]
SEED = 42
PROMPT_CHUNK = (
"生成AIの推論最適化では、KVキャッシュ、量子化、動的バッチングなどの手法が活用され、"
"入力長に応じたメモリ効率とレイテンシのトレードオフが重要とされます。"
"高負荷時における安定性とスループットの確保は、現場導入を進める上での鍵となります。"
)
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, set_seed
def resolve_dtype(dtype_str):
if dtype_str == "auto":
return None
if not hasattr(torch, dtype_str):
raise ValueError(f"Unsupported dtype: {dtype_str}")
return getattr(torch, dtype_str)
def load_model_and_tokenizer(name: str, dtype="float32", device="cpu"):
tokenizer = AutoTokenizer.from_pretrained(name)
dtype_obj = resolve_dtype(dtype)
model_kwargs = {}
if dtype_obj is not None:
model_kwargs["torch_dtype"] = dtype_obj
model = AutoModelForCausalLM.from_pretrained(name, **model_kwargs)
if dtype_obj is not None:
model = model.to(dtype=dtype_obj)
model = model.to(device)
return tokenizer, model
set_seed(SEED)
lfm_tok, lfm_model = load_model_and_tokenizer(LFM_MODEL_NAME, DTYPE, COMPUTE_DEVICE)
qwen_tok, qwen_model = load_model_and_tokenizer(QWEN_MODEL_NAME, DTYPE, COMPUTE_DEVICE)
print(f"Models loaded on {COMPUTE_DEVICE} with dtype={DTYPE}")
import threading, time, psutil, queue, os, sys, subprocess, re, math
class SysMonitor:
def __init__(self, interval=0.1):
self.interval = interval
self.q = queue.Queue()
self._stop = threading.Event()
self._th = None
self.proc = psutil.Process(os.getpid())
self._platform = sys.platform
try:
self._page_size = os.sysconf('SC_PAGE_SIZE')
except (AttributeError, ValueError):
self._page_size = 4096
def _linux_smaps(self):
data = {}
path = f"/proc/{self.proc.pid}/smaps_rollup"
try:
with open(path) as fh:
for line in fh:
if line.startswith('Pss:'):
data['smaps_pss_bytes'] = int(line.split()[1]) * 1024
elif line.startswith('FilePss:'):
data['smaps_file_pss_bytes'] = int(line.split()[1]) * 1024
elif line.startswith('Swap:'):
data['smaps_swap_bytes'] = int(line.split()[1]) * 1024
except (FileNotFoundError, PermissionError):
pass
return data
def _darwin_vm_stat(self):
data = {}
try:
out = subprocess.check_output(['vm_stat'], text=True)
except Exception:
return data
page_size = self._page_size
for line in out.splitlines():
m = re.search(r'page size of (\d+)', line)
if m:
page_size = int(m.group(1))
continue
m = re.match(r'"?([A-Za-z\- ]+)"?:\s+([\d\.]+)', line)
if not m:
continue
key = m.group(1).strip().strip('"')
val = int(float(m.group(2)))
if key == 'File-backed pages':
data['file_backed_bytes'] = val * page_size
elif key == 'Anonymous pages':
data['anonymous_bytes'] = val * page_size
return data
def _collect_os_memory(self):
if self._platform.startswith('linux'):
return self._linux_smaps()
if self._platform == 'darwin':
return self._darwin_vm_stat()
return {}
def _run(self):
while not self._stop.is_set():
ts = time.time()
try:
full = self.proc.memory_full_info()
except psutil.Error:
full = self.proc.memory_info()
vm = psutil.virtual_memory()
swap = psutil.swap_memory()
cpu = psutil.cpu_percent(interval=None)
sample = {
'ts': ts,
'vm_percent': vm.percent,
'rss': getattr(full, 'rss', None),
'uss': getattr(full, 'uss', None),
'cpu_percent': cpu,
'swap_used': getattr(swap, 'used', 0),
}
sample.update(self._collect_os_memory())
self.q.put(sample)
time.sleep(self.interval)
def start(self):
psutil.cpu_percent(interval=None)
self._stop.clear()
self._th = threading.Thread(target=self._run, daemon=True)
self._th.start()
def stop(self):
self._stop.set()
if self._th:
self._th.join()
def collect(self):
out = []
while not self.q.empty():
out.append(self.q.get())
return out
def plot_mem(series, title="System Memory Usage"):
if not series:
print("No samples.")
return
import matplotlib.pyplot as plt
t0 = series[0]['ts']
t = [s['ts'] - t0 for s in series]
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes = axes.flatten()
axes[0].plot(t, [s['vm_percent'] for s in series])
axes[0].set_xlabel('Time [s]')
axes[0].set_ylabel('System Memory [%]')
axes[0].set_title(f"{title} - System %")
rss = [s.get('rss') / (1024 ** 3) if s.get('rss') is not None else float('nan') for s in series]
uss = [s.get('uss') / (1024 ** 3) if s.get('uss') is not None else float('nan') for s in series]
axes[1].plot(t, rss, label='RSS')
if any(not math.isnan(v) for v in uss):
axes[1].plot(t, uss, label='USS')
axes[1].set_xlabel('Time [s]')
axes[1].set_ylabel('Process Memory [GB]')
axes[1].set_title(f"{title} - Process RSS/USS")
if axes[1].lines:
axes[1].legend()
swap = [s.get('swap_used', 0) / (1024 ** 3) for s in series]
axes[2].plot(t, swap)
axes[2].set_xlabel('Time [s]')
axes[2].set_ylabel('Swap Used [GB]')
axes[2].set_title(f"{title} - Swap Usage")
file_key = 'smaps_file_pss_bytes' if any('smaps_file_pss_bytes' in s for s in series) else 'file_backed_bytes'
file_vals = [s.get(file_key, 0) / (1024 ** 3) for s in series]
anon_vals = [s.get('anonymous_bytes', 0) / (1024 ** 3) for s in series]
pss_vals = [s.get('smaps_pss_bytes', 0) / (1024 ** 3) for s in series]
if any(v > 0 for v in file_vals + anon_vals + pss_vals):
if any(v > 0 for v in file_vals):
axes[3].plot(t, file_vals, label='File-backed')
if any(v > 0 for v in anon_vals):
axes[3].plot(t, anon_vals, label='Anonymous')
if any(v > 0 for v in pss_vals):
axes[3].plot(t, pss_vals, label='PSS')
axes[3].set_xlabel('Time [s]')
axes[3].set_ylabel('Memory [GB]')
axes[3].set_title(f"{title} - Mapped Pages")
if axes[3].lines:
axes[3].legend()
else:
axes[3].set_axis_off()
plt.tight_layout()
plt.show()
import time
import torch
import pandas as pd
from IPython.display import display
def build_prompt_text(target_tokens, base_chunk=PROMPT_CHUNK, approx_chars_per_token=4):
needed_chars = target_tokens * approx_chars_per_token
repeat = (needed_chars // (len(base_chunk) + 1)) + 2
prompt = ((base_chunk + "\n") * repeat)[:needed_chars]
return prompt
def summarize_series(series, key):
vals = [s.get(key) for s in series if s.get(key) is not None]
return max(vals) if vals else None
def generate_benchmark(model, tokenizer, prompt_text, max_new_tokens=128, warmup_new_tokens=8):
device = next(model.parameters()).device
inputs = tokenizer(prompt_text, return_tensors="pt")
input_tokens = inputs["input_ids"].shape[-1]
inputs = {k: v.to(device) for k, v in inputs.items()}
if warmup_new_tokens:
with torch.no_grad():
model.generate(**inputs, max_new_tokens=warmup_new_tokens, do_sample=False, use_cache=True)
if torch.cuda.is_available():
torch.cuda.synchronize()
if torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats()
monitor = SysMonitor(interval=0.05)
monitor.start()
t0 = time.time()
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, use_cache=True)
if torch.cuda.is_available():
torch.cuda.synchronize()
elapsed = time.time() - t0
monitor.stop()
series = monitor.collect()
total_tokens = outputs.shape[-1]
new_tokens = max(total_tokens - input_tokens, 0)
gpu_peak_gb = (torch.cuda.max_memory_allocated() / 1e9) if torch.cuda.is_available() else 0.0
return {
"sec": elapsed,
"input_tokens": input_tokens,
"new_tokens": new_tokens,
"tok_per_s": (new_tokens / elapsed) if elapsed > 0 else float("nan"),
"gpu_gb_peak": gpu_peak_gb,
"series": series,
"output": tokenizer.decode(outputs[0], skip_special_tokens=True)[:500],
"rss_peak_bytes": summarize_series(series, "rss"),
"uss_peak_bytes": summarize_series(series, "uss"),
"swap_peak_bytes": summarize_series(series, "swap_used"),
"mapped_peak_bytes": summarize_series(series, "smaps_file_pss_bytes") or summarize_series(series, "file_backed_bytes"),
}
records = []
for label, (tokenizer, model) in [("LFM", (lfm_tok, lfm_model)), ("Qwen", (qwen_tok, qwen_model))]:
print(f"=== {label} benchmark (device={COMPUTE_DEVICE}, dtype={DTYPE}) ===")
for target_len in PROMPT_LENGTHS:
prompt_text = build_prompt_text(target_len)
result = generate_benchmark(
model,
tokenizer,
prompt_text,
max_new_tokens=MAX_NEW_TOKENS,
warmup_new_tokens=WARMUP_NEW_TOKENS,
)
records.append({
"model": label,
"target_prompt_tokens": target_len,
"input_tokens": result["input_tokens"],
"new_tokens": result["new_tokens"],
"tok_per_s": result["tok_per_s"],
"wall_time_sec": result["sec"],
"rss_peak_gb": (result["rss_peak_bytes"] or 0) / (1024 ** 3),
"uss_peak_gb": (result["uss_peak_bytes"] or 0) / (1024 ** 3),
"swap_peak_gb": (result["swap_peak_bytes"] or 0) / (1024 ** 3),
"mapped_peak_gb": (result["mapped_peak_bytes"] or 0) / (1024 ** 3),
})
plot_mem(result["series"], title=f"{label} prompt≈{target_len} tokens")
print(
f"{label} | target={target_len} | actual_in={result['input_tokens']} | "
f"tok/s={result['tok_per_s']:.2f} | wall={result['sec']:.2f}s"
)
if torch.cuda.is_available():
print(f" GPU peak memory: {result['gpu_gb_peak']:.3f} GB")
summary_df = pd.DataFrame(records)
display(summary_df)
結果


512トークンを生成する場合、RSSとUSSはLFMの方が低いようです。


2048トークンを生成する場合、若干、RSSとUSSはLFMの方が低いようです。
またtoken/sはLFM2の方が大きく、walltime(応答までの秒数)はLFM2の方が短くなっています。


8192トークンを生成する場合、もはやRSSとUSSの差はあまりなさそうです。
token/sはLFM2の方が大きく、walltime(応答までの秒数)はLFM2の方が短い傾向は残っています。


ちなみに、16384トークンを生成した場合も同様の傾向が見られましたが、下記のGPTまとめにあるように、actual_inがtargetのtokenの3倍くらいになっていました。
| model | target_prompt_tokens | input_tokens | new_tokens | tok_per_s | wall_time_sec | rss_peak_gb | uss_peak_gb | swap_peak_gb | mapped_peak_gb |
|---|---|---|---|---|---|---|---|---|---|
| LFM | 512 | 1431 | 256 | 9.239644 | 27.706694 | 2.955368 | 2.550400 | 59.923950 | 4.011017 |
| LFM | 2048 | 5717 | 256 | 7.191869 | 35.595756 | 2.588303 | 1.895096 | 59.502075 | 2.406876 |
| LFM | 8192 | 22857 | 256 | 2.768608 | 92.465238 | 4.064514 | 2.276474 | 59.893738 | 2.501022 |
| LFM | 16384 | 45712 | 26 | 0.219486 | 118.458409 | 4.892868 | 3.046844 | 61.392090 | 2.564072 |
| Qwen | 512 | 1462 | 256 | 7.802807 | 32.808707 | 6.085373 | 4.113785 | 61.290527 | 2.649109 |
| Qwen | 2048 | 5853 | 256 | 4.810232 | 53.219882 | 2.874817 | 2.187973 | 60.696777 | 2.716232 |
| Qwen | 8192 | 23404 | 256 | 1.675937 | 152.750402 | 4.401276 | 3.115860 | 60.105286 | 2.803894 |
| Qwen | 16384 | 46813 | 256 | 0.704879 | 363.182963 | 5.616989 | 4.128571 | 61.683167 | 2.941635 |
日本語でトークン数を見積もっていたため、approx_chars_per_tokenのデフォルトを1とかにすべきでした。
(もし検証してみる人がいたら、approx_chars_per_tokenを1にした方が良いかと)
ですので、実際の処理時間などは参考値です。
GPTによるまとめ
軽量CPU推論ならLFMが有利な帯域が広い
同条件(CPU/float32/256出力)で、512〜8k級の長さではLFMが1.2〜1.7倍程度速い。しかもRSSが常に低いため、メモリ逼迫しやすい環境(ノートPC・タブレット)で有利に見える。
超長文域(≳16k)での挙動は要再検証
LFMは「actual_in ≈ 45,712 tokens」で26トークンしか生成せず終了。有効コンテキスト上限・トークナイザ差・EOS/長さ制約のどれかに引っかかった可能性。Qwenは同条件で256出力しており、“極端に長い入力”ではQwenのほうが粘る一面が見える。ただし今回の16kケース自体が当初想定よりはるかに長い実トークン数で走っている点に注意。
メモリ効率は一貫してLFM優位
各長さでRSSはLFMの方が小さい。file-backed(マップド)もQwenがやや高め。オンデバイス実装や同時多本推論では、LFMの方が同一RAMでの同時実行本数を増やしやすい可能性。
Qwen3-0.6Bでの比較結果
| model | target_prompt_tokens | input_tokens | new_tokens | tok_per_s | wall_time_sec | rss_peak_gb | uss_peak_gb | swap_peak_gb | mapped_peak_gb |
|---|---|---|---|---|---|---|---|---|---|
| LFM | 512 | 1431 | 256 | 9.453274 | 27.080564 | 1.830536 | 1.591370 | 63.014038 | 3.759613 |
| LFM | 2048 | 5717 | 256 | 6.263960 | 40.868713 | 2.207642 | 1.662888 | 62.924561 | 3.704407 |
| LFM | 8192 | 22857 | 256 | 3.006236 | 85.156326 | 3.918472 | 2.385727 | 64.054077 | 3.334076 |
| Qwen | 512 | 1462 | 256 | 3.041807 | 84.160513 | 4.440308 | 3.690994 | 63.509216 | 3.536728 |
| Qwen | 2048 | 5853 | 256 | 1.496960 | 171.013219 | 4.787827 | 3.078491 | 60.512878 | 3.298141 |
| Qwen | 8192 | 23404 | 256 | 0.280379 | 913.049601 | 9.420700 | 8.258942 | 66.086975 | 3.465591 |
token/sも、wall_timeもrss_peakもLFMの方が優秀みたいな結果が出ました。
精度(Perplexity)
Perplexity(困惑度)「モデルがテキストをどれだけ予測できるか」を示す指標。
数式的には exp(平均損失) で、
- 低いほど 確率分布が自然言語に近い
- 高いほど 予測が不確実(確信が持てない)
=「小さいほど賢い」スコアです。
from datasets import load_dataset
import torch, math
def calc_ppl(model, tokenizer, texts, max_length=1024):
model.eval()
losses = []
for txt in texts:
if not txt.strip(): continue
enc = tokenizer(txt, return_tensors="pt", truncation=True, max_length=max_length).to(model.device)
with torch.no_grad():
out = model(**enc, labels=enc["input_ids"])
loss = out.loss.item()
losses.append(loss)
return math.exp(sum(losses)/len(losses)) if losses else float("inf")
ds = load_dataset("wikitext", "wikitext-2-raw-v1", split="test[:200]")
texts = [t for t in ds["text"] if t and not t.isspace()][:64]
print("Calculating Perplexity... (subset)")
lfm_ppl = calc_ppl(lfm_model, lfm_tok, texts)
qwen_ppl = calc_ppl(qwen_model, qwen_tok, texts)
print("LFM PPL :", lfm_ppl)
print("Qwen PPL:", qwen_ppl)
Perplexityを比べると、下記の結果だったので、自然言語理解・一般テキストの再現精度ではQwen3が若干上との結果が出ました。
Calculating Perplexity… (subset)
LFM PPL : 177.8903552972885
Qwen PPL: 159.65259927195328
まとめ
Liquid AIのモデルはLEAPというプラットフォームも合わせてAndroidやiOS向けに最適化もされているようなので、モバイル端末だとさらに違いが出るかもしれません。
また、FineTuningのNotebookも用意されているので、FineTuningも比較的やりやすそうです。

LLM/SLM on Edgeが来るぞというニュースが出てきているので、省メモリ、省電力なモデルの進化には期待しかありません。
元の記事を確認する