Turns a branch of accumulated edge/CPU work into something someone else can
read, reproduce and continue.
README.md states what the fork is for (multimodal models on edge CPUs rather
than servers), the headline memory and speed results with their costs, and why
the numbers are worth reading -- five retracted conclusions, all measurement
errors rather than coding errors. Upstream's README is preserved at
docs/README.vllm-omni.md and NOTICE records the Apache-2.0 derivation and the
files this fork changes.
docs/edge/:
methodology.md the measurement discipline, which is the most transferable
thing here: what each of the five retractions was, and the
rule it left behind. Includes the two traps specific to this
stack -- vLLM's AOT compile cache keying on config so two
arms sharing an artifact load the wrong graph, and allocated
not being resident until mincore(2) says so.
results.md every measured figure with its spread and baseline, and an
explicit note that the four memory levers overlap and must
not be added.
cpu-4bit.md the two 4-bit paths, the packed layout as measured rather
than assumed, why the path choice is a config field, and the
GPTQ output-head trap that invalidated a whole round of W4A8
numbers.
harness.md how to run a comparison, and the two arguments that matter
more than they look (--context-len, --prompt-len), each of
which has produced a wrong conclusion by being left at its
default.
ROADMAP.md orders by whether the verification method is correct rather than by
expected size, lists what is done with results, what is next with expected
gains and how each would be verified, and -- at equal length -- what was
considered and rejected, including the CPU megakernel with the measurements
that rule it out for now.
benchmarks/edge_harness/ brings the tooling into the repo so the numbers are
reproducible: the grid runner, the single-run benchmarks, the memory ledger,
the fidelity metric, the Android agent suite, the quantizers, and the tests
that pin the harness's judgement. 82 tests pass from the new location.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
225 lines
8.8 KiB
Python
225 lines
8.8 KiB
Python
"""Where the resident memory actually is, by attribution rather than subtraction.
|
|
|
|
The report's breakdown was arithmetic: weights, imports, and "unexplained
|
|
~1.6 GB" left over after subtracting the first two from the total. Three
|
|
problems with that. It assigned a bf16 embedding to a checkpoint whose config
|
|
says ``quantize_embedding: true``; it left the prefill duplicate out of the
|
|
weight line entirely; and a remainder computed from two wrong terms is not a
|
|
measurement of anything.
|
|
|
|
So this counts instead:
|
|
|
|
* **Unique tensor storages.** Walking ``named_parameters`` double-counts tied
|
|
weights -- Spark ties its embedding to its head, and the quantized path ties
|
|
five tensors at once -- so storages are keyed by ``data_ptr`` and counted
|
|
once, and the tied ones are reported separately rather than silently
|
|
dropped.
|
|
* **The KV cache**, found by attribute rather than assumed from a formula --
|
|
and split into *allocated* and *resident*. A cache that has been allocated
|
|
but never written costs address space, not memory, so counting its size as
|
|
attributed memory would hide the remainder rather than explain it.
|
|
Residency comes from ``mincore(2)``, which reports per-page whether a
|
|
mapping is actually in core.
|
|
* **Every mapping in /proc/self/smaps**, bucketed into heap, anonymous, file
|
|
and the rest. ``smaps_rollup`` cannot do this: it is an aggregate, which is
|
|
the whole thing this is trying to break apart.
|
|
|
|
What it cannot do is attribute native allocations that no longer belong to a
|
|
live tensor -- freed allocator arenas, inductor's generated code, transient
|
|
buffers the caching allocator is holding. Those land in "unattributed anon",
|
|
and the point of the exercise is to see how big that really is once the
|
|
attributable part is counted properly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse, collections, json, os, sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from bench_harness import provenance # noqa: E402
|
|
|
|
|
|
def resident_mb(ptr: int, nbytes: int) -> float:
|
|
"""How much of a mapping is actually in core, via mincore(2).
|
|
|
|
Allocated is not resident: an untouched KV cache is address space. Without
|
|
this the ledger would "explain" memory the process never used.
|
|
"""
|
|
import ctypes
|
|
|
|
if nbytes <= 0:
|
|
return 0.0
|
|
page = os.sysconf("SC_PAGE_SIZE")
|
|
start = ptr - (ptr % page) # mincore needs a page boundary
|
|
length = nbytes + (ptr - start)
|
|
pages = (length + page - 1) // page
|
|
vec = (ctypes.c_ubyte * pages)()
|
|
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
|
if libc.mincore(ctypes.c_void_p(start), ctypes.c_size_t(length), vec) != 0:
|
|
return -1.0 # unknown, rather than a guess
|
|
return round(sum(v & 1 for v in vec) * page / 2**20, 1)
|
|
|
|
|
|
def _rss(field: str) -> float:
|
|
with open("/proc/self/status") as f:
|
|
for line in f:
|
|
if line.startswith(field):
|
|
return round(int(line.split()[1]) / 1024, 1)
|
|
return 0.0
|
|
|
|
|
|
def smaps_breakdown() -> dict[str, float]:
|
|
"""Anonymous resident bytes per mapping kind. Needs smaps, not rollup."""
|
|
buckets: collections.Counter = collections.Counter()
|
|
name = "[anon]"
|
|
try:
|
|
with open("/proc/self/smaps") as f:
|
|
for line in f:
|
|
if not line[:1].isspace() and "-" in line.split()[0]:
|
|
parts = line.split()
|
|
path = parts[5] if len(parts) > 5 else "[anon]"
|
|
if path.startswith("["):
|
|
name = path
|
|
elif path.endswith(".so") or ".so." in path:
|
|
name = "shared libs"
|
|
elif path:
|
|
name = "file-backed"
|
|
else:
|
|
name = "[anon]"
|
|
elif line.startswith("Anonymous:"):
|
|
buckets[name] += int(line.split()[1])
|
|
except OSError:
|
|
return {}
|
|
return {k: round(v / 1024, 1) for k, v in buckets.most_common()}
|
|
|
|
|
|
def storage_ledger(model) -> dict:
|
|
"""Unique tensor storages, by role, counting each byte once."""
|
|
import torch
|
|
|
|
seen: dict[int, int] = {}
|
|
biggest: list = []
|
|
by_role: collections.Counter = collections.Counter()
|
|
counts: collections.Counter = collections.Counter()
|
|
tied_bytes = 0
|
|
|
|
def role_of(name: str) -> str:
|
|
low = name.lower()
|
|
if "embed" in low or "lm_head" in low:
|
|
return "embedding / head"
|
|
for key in ("int4pack", "halves", "scales_and_zeros", "group_scale",
|
|
"group_zero", "weight_scale", "weight_zero", "weight_packed"):
|
|
if key in low:
|
|
return f"quant:{key}"
|
|
if "weight" in low:
|
|
return "other weights"
|
|
return "other"
|
|
|
|
for name, tensor in list(model.named_parameters()) + list(model.named_buffers()):
|
|
if not isinstance(tensor, torch.Tensor) or tensor.device.type != "cpu":
|
|
continue
|
|
try:
|
|
ptr = tensor.untyped_storage().data_ptr()
|
|
nbytes = tensor.untyped_storage().nbytes()
|
|
except Exception:
|
|
ptr, nbytes = tensor.data_ptr(), tensor.numel() * tensor.element_size()
|
|
if ptr in seen:
|
|
tied_bytes += nbytes # tied / shared: counted once already
|
|
continue
|
|
seen[ptr] = nbytes
|
|
by_role[role_of(name)] += nbytes
|
|
counts[role_of(name)] += 1
|
|
if nbytes >= 16 * 2**20:
|
|
biggest.append((name, nbytes, resident_mb(ptr, nbytes),
|
|
tuple(tensor.shape), tensor.dtype))
|
|
|
|
biggest.sort(key=lambda t: -t[1])
|
|
return {
|
|
"unique_storage_mb": round(sum(seen.values()) / 2**20, 1),
|
|
"tied_duplicate_mb": round(tied_bytes / 2**20, 1),
|
|
"by_role_mb": {k: round(v / 2**20, 1) for k, v in by_role.most_common()},
|
|
"tensors_by_role": dict(counts),
|
|
# "other" is where an unclassified tensor hides, so name the largest
|
|
"largest_tensors": [
|
|
{"name": n, "mb": round(b / 2**20, 1), "resident_mb": r,
|
|
"shape": list(sh), "dtype": str(dt)}
|
|
for n, b, r, sh, dt in biggest[:15]
|
|
],
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--model", required=True)
|
|
ap.add_argument("--quantization", default=None)
|
|
ap.add_argument("--max-model-len", type=int, default=2048)
|
|
ap.add_argument("--tag", default="ledger")
|
|
ap.add_argument("--out", required=True)
|
|
a = ap.parse_args()
|
|
|
|
anon_before = _rss("RssAnon:")
|
|
import vllm_omni # noqa: F401
|
|
import torch
|
|
from vllm import LLM
|
|
|
|
anon_after_import = _rss("RssAnon:")
|
|
llm = LLM(model=a.model, dtype="bfloat16", max_model_len=a.max_model_len,
|
|
max_num_seqs=1, quantization=a.quantization,
|
|
enable_prefix_caching=False, trust_remote_code=False,
|
|
disable_log_stats=True)
|
|
|
|
# The model only exists in this process when the executor stayed uniproc;
|
|
# say so rather than reporting a ledger of nothing.
|
|
try:
|
|
engine = llm.llm_engine
|
|
runner = engine.engine_core.engine_core.model_executor.driver_worker.model_runner
|
|
model = runner.model
|
|
except AttributeError as exc:
|
|
raise SystemExit(
|
|
f"cannot reach the model in this process ({exc}). Set "
|
|
f"VLLM_ENABLE_V1_MULTIPROCESSING=0 so the executor stays uniproc; "
|
|
f"otherwise the weights are in a worker and there is nothing here "
|
|
f"to attribute."
|
|
)
|
|
|
|
ledger = storage_ledger(model)
|
|
|
|
kv_mb = kv_resident = 0.0
|
|
try:
|
|
caches = runner.kv_caches
|
|
seen = set()
|
|
for c in caches:
|
|
if isinstance(c, torch.Tensor) and c.data_ptr() not in seen:
|
|
seen.add(c.data_ptr())
|
|
nbytes = c.numel() * c.element_size()
|
|
kv_mb += nbytes / 2**20
|
|
r = resident_mb(c.data_ptr(), nbytes)
|
|
kv_resident += max(r, 0.0)
|
|
except Exception:
|
|
kv_mb = kv_resident = -1.0
|
|
|
|
anon_total = _rss("RssAnon:")
|
|
# Only resident KV counts: allocated-but-untouched cache is address space.
|
|
attributed = (ledger["unique_storage_mb"] + max(kv_resident, 0.0)
|
|
+ anon_after_import)
|
|
rec = {
|
|
"tag": a.tag, "model": a.model, "quantization": a.quantization,
|
|
"anon_before_import_mb": anon_before,
|
|
"anon_after_import_mb": anon_after_import,
|
|
"anon_total_mb": anon_total,
|
|
"kv_cache_allocated_mb": round(kv_mb, 1),
|
|
"kv_cache_resident_mb": round(kv_resident, 1),
|
|
**ledger,
|
|
"attributed_mb": round(attributed, 1),
|
|
"unattributed_anon_mb": round(anon_total - attributed, 1),
|
|
"smaps_anon_mb": smaps_breakdown(),
|
|
"provenance": provenance({"scope": "in-process ledger"}),
|
|
}
|
|
print(json.dumps({k: v for k, v in rec.items() if k != "provenance"}, indent=2))
|
|
with open(a.out, "w") as f:
|
|
json.dump(rec, f, indent=2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|