mjolnir-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/gate.py ADDED
@@ -0,0 +1,233 @@
1
+ """Reusable clean-window gate for Thor GPU benchmarks.
2
+
3
+ The Thor GPU is shared with the live vLLM server. A valid benchmark window
4
+ requires the vLLM request queue to be empty (no running, no waiting) for
5
+ several consecutive samples — the "clean window". This module is the single
6
+ source of truth for that gate; the kernel bench scripts carry inline copies
7
+ of the same logic.
8
+
9
+ The vLLM server is NEVER stopped or restarted to get a clean window — the
10
+ gate only waits for one to open on its own.
11
+
12
+ Metrics source: the vLLM server's Prometheus endpoint (``/metrics`` on its
13
+ serving port). We read ``vllm:num_requests_running`` and
14
+ ``vllm:num_requests_waiting``; the window is clean when both are 0.0.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import re
21
+ import time
22
+ import urllib.request
23
+
24
+ DEFAULT_POLL_S = 2.0
25
+ DEFAULT_CONFIRM = 6
26
+ DEFAULT_TIMEOUT_S = 3600
27
+
28
+ _RUNNING = "vllm:num_requests_running"
29
+ _WAITING = "vllm:num_requests_waiting"
30
+
31
+
32
+ class PreflightError(RuntimeError):
33
+ """The vLLM metrics endpoint was unreachable (server not up / wrong port)."""
34
+
35
+
36
+ def server_load(metrics_url: str) -> tuple[float, float] | None:
37
+ """Return ``(num_running, num_waiting)`` from the vLLM server, or ``None``
38
+ if the endpoint is unreachable or the metrics are missing."""
39
+ try:
40
+ with urllib.request.urlopen(metrics_url, timeout=5) as r:
41
+ text = r.read().decode()
42
+ except Exception: # noqa: BLE001
43
+ return None
44
+ out: dict[str, float] = {}
45
+ for line in text.splitlines():
46
+ for key in (_RUNNING, _WAITING):
47
+ if line.startswith(key + "{") or line.startswith(key + " "):
48
+ out[key.split(":", 1)[1]] = float(line.rsplit(" ", 1)[1])
49
+ if "num_requests_running" not in out or "num_requests_waiting" not in out:
50
+ return None
51
+ return (out["num_requests_running"], out["num_requests_waiting"])
52
+
53
+
54
+ _SPEC = {
55
+ "vllm:spec_decode_num_drafts_total": "drafts",
56
+ "vllm:spec_decode_num_draft_tokens_total": "draft_tokens",
57
+ "vllm:spec_decode_num_accepted_tokens_total": "accepted",
58
+ "vllm:num_preemptions_total": "preemptions",
59
+ "vllm:generation_tokens_total": "generation_tokens",
60
+ }
61
+ _POS = "vllm:spec_decode_num_accepted_tokens_per_pos_total"
62
+
63
+
64
+ def server_counters(metrics_url: str) -> dict | None:
65
+ """Speculative-decoding / scheduler counters (summed over label sets), for
66
+ before/after deltas around a bench. None if unreachable."""
67
+ try:
68
+ with urllib.request.urlopen(metrics_url, timeout=5) as r:
69
+ text = r.read().decode()
70
+ except Exception: # noqa: BLE001
71
+ return None
72
+ out: dict = {"per_pos": {}}
73
+ for line in text.splitlines():
74
+ if not line or line[0] == "#":
75
+ continue
76
+ name = line.split("{", 1)[0].split(" ", 1)[0]
77
+ try:
78
+ val = float(line.rsplit(" ", 1)[1])
79
+ except (IndexError, ValueError):
80
+ continue
81
+ if name in _SPEC:
82
+ out[_SPEC[name]] = out.get(_SPEC[name], 0.0) + val
83
+ elif name == _POS:
84
+ m = re.search(r'position="(\d+)"', line)
85
+ if m:
86
+ k = int(m.group(1))
87
+ out["per_pos"][k] = out["per_pos"].get(k, 0.0) + val
88
+ return out
89
+
90
+
91
+ def spec_delta(before: dict | None, after: dict | None) -> dict | None:
92
+ """Acceptance stats between two :func:`server_counters` samples:
93
+ mean acceptance length (1 + accepted / drafts: tokens per verify step),
94
+ per-position acceptance rate, preemptions."""
95
+ if not before or not after:
96
+ return None
97
+ d = {k: after.get(k, 0.0) - before.get(k, 0.0) for k in set(_SPEC.values())}
98
+ if d.get("drafts", 0) <= 0:
99
+ return {"preemptions": int(d.get("preemptions", 0))} if "preemptions" in after else None
100
+ counts = {
101
+ k: after["per_pos"].get(k, 0.0) - before.get("per_pos", {}).get(k, 0.0)
102
+ for k in sorted(after.get("per_pos", {}))
103
+ }
104
+ pos = {k: v / d["drafts"] for k, v in counts.items()}
105
+ return {
106
+ "drafts": int(d["drafts"]),
107
+ "draft_tokens": int(d.get("draft_tokens", 0)),
108
+ "accepted": int(d.get("accepted", 0)),
109
+ "acceptance_length": round(1 + d.get("accepted", 0) / d["drafts"], 3),
110
+ "acceptance_rate": round(d.get("accepted", 0) / d["draft_tokens"], 4) if d.get("draft_tokens") else None,
111
+ "per_pos": [round(v, 4) for v in pos.values()],
112
+ "per_pos_accepted": [int(v) for v in counts.values()], # raw counts, for pooling
113
+ "preemptions": int(d.get("preemptions", 0)),
114
+ }
115
+
116
+
117
+ def wait_for_idle(
118
+ metrics_url: str,
119
+ poll_s: float = DEFAULT_POLL_S,
120
+ confirm: int = DEFAULT_CONFIRM,
121
+ timeout_s: float = DEFAULT_TIMEOUT_S,
122
+ ) -> float | None:
123
+ """Block until ``confirm`` consecutive samples read (0,0); return the
124
+ ``time.time()`` of the sample that opened the window, or ``None`` on
125
+ timeout."""
126
+ t_start = time.perf_counter()
127
+ streak = 0
128
+ while time.perf_counter() - t_start < timeout_s:
129
+ load = server_load(metrics_url)
130
+ if load == (0.0, 0.0):
131
+ streak += 1
132
+ if streak >= confirm:
133
+ return time.time()
134
+ else:
135
+ streak = 0
136
+ time.sleep(poll_s)
137
+ return None
138
+
139
+
140
+ class _Monitor(threading.Thread):
141
+ """Background thread that records ``(time, load)`` samples."""
142
+
143
+ def __init__(self, metrics_url: str, poll_s: float) -> None:
144
+ super().__init__(daemon=True)
145
+ self.metrics_url = metrics_url
146
+ self.poll_s = poll_s
147
+ self.trace: list[tuple[float, tuple[float, float] | None]] = []
148
+ self._stop = threading.Event()
149
+
150
+ def run(self) -> None:
151
+ while not self._stop.is_set():
152
+ self.trace.append((time.time(), server_load(self.metrics_url)))
153
+ self._stop.wait(self.poll_s)
154
+
155
+ def stop(self) -> None:
156
+ self._stop.set()
157
+
158
+
159
+ class CleanWindow:
160
+ """Context manager: open a gated clean window, then report whether it
161
+ stayed clean for the whole body.
162
+
163
+ ``open_ts`` is set once the window opens. ``clean`` is True iff every
164
+ in-window sample read (0,0). Raises :class:`PreflightError` if the
165
+ metrics endpoint is unreachable at start, :class:`TimeoutError` if the
166
+ window never opens within ``timeout_s``.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ metrics_url: str,
172
+ confirm: int = DEFAULT_CONFIRM,
173
+ poll_s: float = DEFAULT_POLL_S,
174
+ timeout_s: float = DEFAULT_TIMEOUT_S,
175
+ ) -> None:
176
+ self.metrics_url = metrics_url
177
+ self.confirm = confirm
178
+ self.poll_s = poll_s
179
+ self.timeout_s = timeout_s
180
+ self.open_ts: float | None = None
181
+ self.clean: bool | None = None
182
+ self.dirty_samples: list = []
183
+
184
+ def __enter__(self) -> "CleanWindow":
185
+ self._mon = _Monitor(self.metrics_url, self.poll_s)
186
+ self._mon.start()
187
+ time.sleep(self.poll_s)
188
+ if all(load is None for _, load in self._mon.trace):
189
+ self._mon.stop()
190
+ raise PreflightError(
191
+ f"vLLM metrics endpoint {self.metrics_url} is unreachable — is the server up? (mjolnir serve up)"
192
+ )
193
+ self._streak = 0
194
+ self._consumed = 0
195
+ t_start = time.perf_counter()
196
+ while True:
197
+ if time.perf_counter() - t_start > self.timeout_s:
198
+ self._mon.stop()
199
+ raise TimeoutError(
200
+ f"timed out after {self.timeout_s:.0f}s waiting for {self.confirm} consecutive 0/0 samples"
201
+ )
202
+ self._drain()
203
+ if self._streak >= self.confirm:
204
+ self.open_ts = self._mon.trace[-1][0]
205
+ return self
206
+ time.sleep(1.0)
207
+
208
+ def _drain(self) -> None:
209
+ while self._consumed < len(self._mon.trace):
210
+ _ts, load = self._mon.trace[self._consumed]
211
+ self._consumed += 1
212
+ self._streak = self._streak + 1 if load == (0.0, 0.0) else 0
213
+
214
+ def __exit__(self, exc_type, exc, tb) -> bool:
215
+ self._mon.stop()
216
+ self._drain()
217
+ in_window = [(ts, load) for (ts, load) in self._mon.trace if self.open_ts is not None and ts >= self.open_ts]
218
+ self.dirty_samples = [[ts, load] for (ts, load) in in_window if load is None or load != (0.0, 0.0)]
219
+ self.clean = not self.dirty_samples
220
+ return False
221
+
222
+ def summary(self) -> dict:
223
+ return {
224
+ "metrics_url": self.metrics_url,
225
+ "poll_s": self.poll_s,
226
+ "confirm": self.confirm,
227
+ "open_ts": self.open_ts,
228
+ "clean": self.clean,
229
+ "n_in_window": (
230
+ sum(1 for ts, _ in self._mon.trace if ts >= self.open_ts) if self.open_ts is not None else 0
231
+ ),
232
+ "dirty_samples": self.dirty_samples,
233
+ }
mjolnir_cli/history.py ADDED
@@ -0,0 +1,159 @@
1
+ """Append-only benchmark history (``benchmarks/history.jsonl``).
2
+
3
+ One line per perf sweep repeat:
4
+
5
+ {
6
+ "ts": "2026-09-25T14:03:11+00:00", "epoch": 1758801791.123,
7
+ "host": "thor-01", "image": "mjolnir/vllm-thor:qwen38-sm110-v11",
8
+ "model": "Qwen/Qwen3.8-27B", "config": "NVFP4_MJOLNIR",
9
+ "backend": "Mjolnir",
10
+ "gate": {"clean": true, "confirm": 3, "open_ts": 1758801600.0},
11
+ "runs": 5, "warmup_runs": 2, "exact_tg": true,
12
+ "cells": [
13
+ {"concurrency": 1, "context": 8192, "prompt": 2048, "gen": 128,
14
+ "tg_tps": {"mean": 41.2, "std": 0.7, "values": [...]}, # decode t/s (all requests)
15
+ "tg_req_tps": {...}, # decode t/s per request
16
+ "pp_tps": {"mean": 9830.0, "std": 120.0, "values": [...]}, # prefill t/s
17
+ "ttfr_ms": {...}, "ttft_ms": {...}, # first response / e2e TTFT
18
+ "itl_ms": {...}}, # inter-token latency = 1000 / tg_req
19
+ ...
20
+ ]
21
+ }
22
+
23
+ The file is the raw, git-trackable substrate for the charts and for
24
+ "progress over time" — every nightly bump / kernel change adds a row.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import sys
31
+ import platform
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+
37
+ def append_record(history_file: Path, record: dict[str, Any]) -> None:
38
+ history_file.parent.mkdir(parents=True, exist_ok=True)
39
+ # a hand-edited file may lack the final newline: never glue a record onto it
40
+ lead = ""
41
+ if history_file.exists() and history_file.stat().st_size:
42
+ with history_file.open("rb") as f:
43
+ f.seek(-1, 2)
44
+ lead = "" if f.read(1) == b"\n" else "\n"
45
+ with history_file.open("a") as f:
46
+ f.write(lead + json.dumps(record) + "\n")
47
+
48
+
49
+ def load_records(history_file: Path) -> list[dict[str, Any]]:
50
+ """Every record, one JSON object per line — tolerant of objects glued
51
+ onto one line (an append after a hand edit that dropped the newline) and
52
+ of a truncated last line (an interrupted write), which is skipped with a
53
+ warning instead of failing every reader."""
54
+ if not history_file.exists():
55
+ return []
56
+ out = []
57
+ dec = json.JSONDecoder()
58
+ with history_file.open() as f:
59
+ for n, line in enumerate(f, 1):
60
+ line = line.strip()
61
+ i = 0
62
+ while i < len(line):
63
+ try:
64
+ rec, i = dec.raw_decode(line, i)
65
+ except json.JSONDecodeError as e:
66
+ print(f"[mjolnir] {history_file}:{n}: skipped unreadable data ({e})", file=sys.stderr)
67
+ break
68
+ out.append(rec)
69
+ while i < len(line) and line[i].isspace():
70
+ i += 1
71
+ return out
72
+
73
+
74
+ def make_record(
75
+ s,
76
+ gate_summary: dict | None,
77
+ runs: int,
78
+ warmup_runs: int,
79
+ exact_tg: bool,
80
+ cells: list[dict[str, Any]],
81
+ raw_relpath: str,
82
+ ) -> dict[str, Any]:
83
+ return {
84
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.gmtime()).replace("+0000", "+00:00"),
85
+ "epoch": time.time(),
86
+ "host": platform.node() or "unknown",
87
+ "image": s.image,
88
+ "model": s.model,
89
+ "config": s.quant,
90
+ "backend": s.backend_label,
91
+ "spec": _spec(s),
92
+ "gate": gate_summary,
93
+ "runs": runs,
94
+ "warmup_runs": warmup_runs,
95
+ "exact_tg": exact_tg,
96
+ "cells": cells,
97
+ "raw": raw_relpath,
98
+ }
99
+
100
+
101
+ def _spec(s) -> dict | None:
102
+ """Speculative decoding the served config used (e2e numbers depend on it)."""
103
+ try:
104
+ from mjolnir_cli.config import config_speculative
105
+
106
+ return config_speculative(s.config_path)
107
+ except Exception: # noqa: BLE001
108
+ return None
109
+
110
+
111
+ def parse_benchy_json(raw: dict[str, Any]) -> list[dict[str, Any]]:
112
+ """llama-benchy JSON → normalized per-cell records with the metrics we
113
+ chart (tg / pp generation + prefill throughput, each with mean/std/values
114
+ from the per-run samples)."""
115
+ cells = []
116
+ for b in raw.get("benchmarks", []):
117
+
118
+ def metric(name: str) -> dict | None:
119
+ m = b.get(name)
120
+ if not isinstance(m, dict):
121
+ return None
122
+ vals = m.get("values")
123
+ out = {"mean": m.get("mean"), "std": m.get("std")}
124
+ if vals is not None:
125
+ out["values"] = [float(v) for v in vals]
126
+ return out
127
+
128
+ cell = {
129
+ "concurrency": b.get("concurrency"),
130
+ "context": b.get("context_size"),
131
+ "prompt": b.get("prompt_size"),
132
+ "gen": b.get("response_size"),
133
+ "prefill_phase": bool(b.get("is_context_prefill_phase", False)),
134
+ }
135
+ for src, dst in (
136
+ ("tg_throughput", "tg_tps"),
137
+ ("tg_req_throughput", "tg_req_tps"),
138
+ ("pp_throughput", "pp_tps"),
139
+ ("peak_throughput", "peak_tps"),
140
+ ("ttfr", "ttfr_ms"),
141
+ ("e2e_ttft", "ttft_ms"),
142
+ ):
143
+ m = metric(src)
144
+ if m is not None:
145
+ cell[dst] = m
146
+ # inter-token latency: the per-request decode rate inverted, per run
147
+ # (at c=1 tg_req == tg). llama-benchy reports rates, not ITL itself.
148
+ if (b.get("concurrency") or 1) == 1:
149
+ rate = cell.get("tg_req_tps") or cell.get("tg_tps")
150
+ else:
151
+ rate = cell.get("tg_req_tps")
152
+ if rate and rate.get("values"):
153
+ itl = [1000.0 / v for v in rate["values"] if v and v > 0]
154
+ if itl:
155
+ mean = sum(itl) / len(itl)
156
+ std = (sum((v - mean) ** 2 for v in itl) / (len(itl) - 1)) ** 0.5 if len(itl) > 1 else 0.0
157
+ cell["itl_ms"] = {"mean": mean, "std": std, "values": itl}
158
+ cells.append(cell)
159
+ return cells