437 lines
13 KiB
Python
437 lines
13 KiB
Python
""" Render-cost comparison between vterm and Terminus
|
|
|
|
Both plugins turn pty output into view text plus add_regions overlays, so what
|
|
they cost the editor is the number of Sublime calls a frame makes -- the plugin
|
|
host is a separate process (see Architecture.md). This drives both render
|
|
commands against a fake view that counts every call, on the same input, with
|
|
each plugin's own render gate deciding when a frame happens.
|
|
|
|
Terminus is read straight out of its .sublime-package by zipimport; it is
|
|
skipped when not installed. Parse times are real, call counts stand in for the
|
|
cross-process cost, which a fake view cannot show.
|
|
|
|
Run with: python3 tools/bench_vs_terminus.py
|
|
"""
|
|
import collections
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(ROOT, "tests"))
|
|
import _bootstrap # noqa: F401 sets sys.path for the imports below
|
|
|
|
TERMINUS = os.path.expanduser(
|
|
"~/Library/Application Support/Sublime Text/Installed Packages/Terminus.sublime-package")
|
|
|
|
import sublime
|
|
|
|
ROWS, COLUMNS = 40, 100
|
|
SCROLLBACK = 10000
|
|
|
|
|
|
class FakeSelection(list):
|
|
def clear(self):
|
|
del self[:]
|
|
|
|
def add(self, region):
|
|
self.append(region)
|
|
|
|
|
|
class CountingView:
|
|
"""A view that answers with real line arithmetic and tallies every call."""
|
|
|
|
_ids = [0]
|
|
|
|
def __init__(self, counter):
|
|
CountingView._ids[0] += 1
|
|
self._id = CountingView._ids[0]
|
|
self.c = counter
|
|
self.text = ""
|
|
self.selection = FakeSelection()
|
|
self.regions = {}
|
|
self.folds = []
|
|
self._name = ""
|
|
self._settings = sublime.Settings()
|
|
# so a plugin that renders through a second command has it counted too
|
|
self.commands = {}
|
|
|
|
def _t(self, name):
|
|
self.c[name] += 1
|
|
|
|
def id(self):
|
|
return self._id
|
|
|
|
def size(self):
|
|
self._t("size")
|
|
return len(self.text)
|
|
|
|
def rowcol(self, point):
|
|
self._t("rowcol")
|
|
head = self.text[:point]
|
|
return (head.count("\n"), point - (head.rfind("\n") + 1))
|
|
|
|
def text_point(self, row, col):
|
|
self._t("text_point")
|
|
lines = self.text.split("\n")
|
|
start = sum(len(l) + 1 for l in lines[:row])
|
|
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
|
|
|
def _line(self, point):
|
|
start = self.text.rfind("\n", 0, point) + 1
|
|
end = self.text.find("\n", point)
|
|
return sublime.Region(start, len(self.text) if end == -1 else end)
|
|
|
|
def line(self, point):
|
|
self._t("line")
|
|
return self._line(point)
|
|
|
|
def full_line(self, point):
|
|
self._t("full_line")
|
|
region = self._line(point)
|
|
return sublime.Region(region.begin(), min(region.end() + 1, len(self.text)))
|
|
|
|
def lines(self, region):
|
|
self._t("lines")
|
|
out = []
|
|
point = region.begin()
|
|
while point <= region.end():
|
|
line = self._line(point)
|
|
out.append(line)
|
|
point = line.end() + 1
|
|
return out
|
|
|
|
def substr(self, region):
|
|
self._t("substr")
|
|
return self.text[region.begin():region.end()]
|
|
|
|
def erase(self, edit, region):
|
|
self._t("erase")
|
|
self.text = self.text[:region.begin()] + self.text[region.end():]
|
|
|
|
def replace(self, edit, region, value):
|
|
self._t("replace")
|
|
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
|
|
|
def insert(self, edit, point, value):
|
|
self._t("insert")
|
|
self.text = self.text[:point] + value + self.text[point:]
|
|
return len(value)
|
|
|
|
def fold(self, region):
|
|
self._t("fold")
|
|
self.folds.append((region.begin(), region.end()))
|
|
return True
|
|
|
|
def unfold(self, region):
|
|
self._t("unfold")
|
|
folded, self.folds = self.folds, []
|
|
return folded
|
|
|
|
def folded_regions(self):
|
|
self._t("folded_regions")
|
|
return [sublime.Region(a, b) for a, b in self.folds]
|
|
|
|
def add_regions(self, key, regions, scope="", *args, **kwargs):
|
|
self._t("add_regions")
|
|
# what crosses the process boundary is the regions, not just the call
|
|
self.c["regions.sent"] += len(regions)
|
|
self.c["regions.sent.merged" if "@" in key else "regions.sent.row"] += len(regions)
|
|
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
|
|
|
def get_regions(self, key):
|
|
self._t("get_regions")
|
|
_, spans = self.regions.get(key, ("", []))
|
|
self.c["regions.read"] += len(spans)
|
|
return [sublime.Region(a, b) for a, b in spans]
|
|
|
|
def erase_regions(self, key):
|
|
self._t("erase_regions")
|
|
self.regions.pop(key, None)
|
|
|
|
def settings(self):
|
|
self._t("settings")
|
|
return CountingSettings(self._settings, self.c)
|
|
|
|
def viewport_position(self):
|
|
self._t("viewport_position")
|
|
return (0, 0)
|
|
|
|
def viewport_extent(self):
|
|
self._t("viewport_extent")
|
|
return (800, ROWS * 20)
|
|
|
|
def layout_extent(self):
|
|
self._t("layout_extent")
|
|
return (800, len(self.text))
|
|
|
|
def text_to_layout(self, point):
|
|
self._t("text_to_layout")
|
|
return (0, point)
|
|
|
|
def set_viewport_position(self, position, animate=True):
|
|
self._t("set_viewport_position")
|
|
|
|
def line_height(self):
|
|
self._t("line_height")
|
|
return 20
|
|
|
|
def em_width(self):
|
|
self._t("em_width")
|
|
return 8
|
|
|
|
def name(self):
|
|
self._t("name")
|
|
return self._name
|
|
|
|
def set_name(self, name):
|
|
self._t("set_name")
|
|
self._name = name
|
|
|
|
def window(self):
|
|
self._t("window")
|
|
return object()
|
|
|
|
def sel(self):
|
|
self._t("sel")
|
|
return self.selection
|
|
|
|
def run_command(self, name, args=None):
|
|
self._t("run_command:" + name)
|
|
command = self.commands.get(name)
|
|
if command:
|
|
command.run(None, **(args or {}))
|
|
|
|
|
|
class CountingSettings:
|
|
"""Settings reads and writes are round trips too, and Terminus does one per run."""
|
|
|
|
def __init__(self, settings, counter):
|
|
self._settings = settings
|
|
self._c = counter
|
|
|
|
def get(self, key, default=None):
|
|
self._c["settings.get"] += 1
|
|
return self._settings.get(key, default)
|
|
|
|
def set(self, key, value):
|
|
self._c["settings.set"] += 1
|
|
self._settings[key] = value
|
|
|
|
def erase(self, key):
|
|
self._c["settings.erase"] += 1
|
|
self._settings.pop(key, None)
|
|
|
|
def has(self, key):
|
|
return key in self._settings
|
|
|
|
def add_on_change(self, *args):
|
|
pass
|
|
|
|
def clear_on_change(self, *args):
|
|
pass
|
|
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.writes = []
|
|
|
|
def write(self, data):
|
|
self.writes.append(data)
|
|
|
|
|
|
# ------------------------------------------------------------------ workloads
|
|
|
|
def colour_flood(lines=2000, runs=6, palette=200):
|
|
"""Coloured output: `runs` SGR changes per line, drawn from `palette` colours."""
|
|
width = COLUMNS // runs
|
|
out = []
|
|
for i in range(lines):
|
|
parts = []
|
|
for r in range(runs):
|
|
parts.append("\x1b[38;5;{}m".format(16 + (i * runs + r) % palette))
|
|
parts.append(str((i + r) % 10) * width)
|
|
parts.append("\x1b[0m\r\n")
|
|
out.append("".join(parts))
|
|
return "".join(out)
|
|
|
|
|
|
def plain_flood(lines=2000):
|
|
return "".join("{:04d} ".format(i) + "x" * (COLUMNS - 5) + "\r\n" for i in range(lines))
|
|
|
|
|
|
def tui_repaint(frames=60):
|
|
"""A full-screen coloured app redrawing itself; one cell differs per frame."""
|
|
out = []
|
|
for f in range(frames):
|
|
parts = ["\x1b[H"]
|
|
for row in range(ROWS):
|
|
for r in range(4):
|
|
parts.append("\x1b[38;5;{}m".format(16 + (row * 4 + r) % 200))
|
|
parts.append(str((row + r) % 10) * (COLUMNS // 4))
|
|
parts.append("\x1b[0m")
|
|
if row < ROWS - 1:
|
|
parts.append("\r\n")
|
|
parts.append("\x1b[1;1H\x1b[38;5;99m{:04d}\x1b[0m".format(f))
|
|
out.append("".join(parts))
|
|
return "".join(out)
|
|
|
|
|
|
# ------------------------------------------------------------------- drivers
|
|
|
|
def drive(feed, need_to_render, render, data, tick):
|
|
"""Feed `data` in tick-sized reads, rendering whenever the plugin asks to."""
|
|
parse = 0.0
|
|
frames = 0
|
|
for i in range(0, len(data), tick):
|
|
start = time.perf_counter()
|
|
feed(data[i:i + tick])
|
|
parse += time.perf_counter() - start
|
|
if need_to_render():
|
|
render()
|
|
frames += 1
|
|
return parse, frames
|
|
|
|
|
|
def run_vterm(data, tick):
|
|
from vterm.emulator import VtScreen, VtStream
|
|
from vterm.render import VtermRenderCommand
|
|
from vterm.terminal import Terminal
|
|
|
|
counter = collections.Counter()
|
|
view = CountingView(counter)
|
|
terminal = Terminal(view)
|
|
terminal.scrollback_size = SCROLLBACK
|
|
terminal.process = FakeProcess()
|
|
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
|
Terminal._terminals[view.id()] = terminal
|
|
stream = VtStream(terminal.screen)
|
|
command = VtermRenderCommand(view)
|
|
|
|
def render():
|
|
counter["run_command:vterm_render"] += 1
|
|
command.run(None)
|
|
|
|
try:
|
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
|
finally:
|
|
del Terminal._terminals[view.id()]
|
|
return counter, parse, frames, len(view.regions)
|
|
|
|
|
|
def run_terminus(data, tick):
|
|
from terminus.ptty import TerminalScreen, TerminalStream
|
|
from terminus.render import TerminusRenderCommand, TerminusShowCursorCommand
|
|
from terminus.terminal import Terminal
|
|
|
|
counter = collections.Counter()
|
|
view = CountingView(counter)
|
|
terminal = Terminal(view)
|
|
terminal.detached = False
|
|
terminal.offset = 0
|
|
terminal.title = None
|
|
terminal.default_title = "Terminus"
|
|
terminal.screen = TerminalScreen(
|
|
COLUMNS, ROWS, process=FakeProcess(), clear_callback=lambda: None,
|
|
reset_callback=lambda: None, history=SCROLLBACK)
|
|
Terminal._terminals[view.id()] = terminal
|
|
stream = TerminalStream(terminal.screen)
|
|
command = TerminusRenderCommand(view)
|
|
command.scrollback_history_size = SCROLLBACK
|
|
view.commands["terminus_show_cursor"] = TerminusShowCursorCommand(view)
|
|
|
|
def render():
|
|
counter["run_command:terminus_render"] += 1
|
|
command.run(None)
|
|
# terminus clears dirty in its render loop, not in the command
|
|
terminal.screen.dirty.clear()
|
|
|
|
try:
|
|
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
|
|
finally:
|
|
del Terminal._terminals[view.id()]
|
|
return counter, parse, frames, len(view.regions)
|
|
|
|
|
|
# -------------------------------------------------------------------- report
|
|
|
|
def measure(runner, data, tick):
|
|
counter, parse, frames, keys = runner(data, tick)
|
|
calls = sum(v for k, v in counter.items() if not k.startswith("regions."))
|
|
return {
|
|
"parse": parse * 1000,
|
|
"frames": frames,
|
|
"calls": calls,
|
|
"per_frame": calls / max(frames, 1),
|
|
"sent": counter["regions.sent"],
|
|
"sent_row": counter["regions.sent.row"],
|
|
"sent_merged": counter["regions.sent.merged"],
|
|
"read": counter["regions.read"],
|
|
"keys": keys,
|
|
"counter": counter,
|
|
}
|
|
|
|
|
|
ROW_FORMAT = " {:<26}{:>14}{:>14}{:>10}"
|
|
|
|
|
|
def report(title, data, tick, both):
|
|
print("=" * 66)
|
|
print("{}\n {:.0f} KiB fed in {} B reads".format(title, len(data) / 1024, tick))
|
|
print("=" * 66)
|
|
ours = measure(run_vterm, data, tick)
|
|
theirs = measure(run_terminus, data, tick) if both else None
|
|
|
|
def line(label, key, fmt="{:.0f}"):
|
|
mine = fmt.format(ours[key])
|
|
if not theirs:
|
|
print(ROW_FORMAT.format(label, mine, "-", "-"))
|
|
return
|
|
other = fmt.format(theirs[key])
|
|
ratio = theirs[key] / ours[key] if ours[key] else 0
|
|
print(ROW_FORMAT.format(label, mine, other, "{:.1f}x".format(ratio)))
|
|
|
|
print(ROW_FORMAT.format("", "vterm", "Terminus", "ratio"))
|
|
line("parse ms", "parse", "{:.1f}")
|
|
line("frames", "frames")
|
|
line("sublime calls", "calls")
|
|
line("calls / frame", "per_frame", "{:.1f}")
|
|
line("regions sent", "sent")
|
|
line(" of them merges", "sent_merged")
|
|
line("regions read back", "read")
|
|
line("live region keys", "keys")
|
|
top = sorted(((v, k) for k, v in ours["counter"].items() if not k.startswith("regions.")),
|
|
reverse=True)[:6]
|
|
print(" vterm top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
|
if theirs:
|
|
top = sorted(((v, k) for k, v in theirs["counter"].items() if not k.startswith("regions.")),
|
|
reverse=True)[:6]
|
|
print(" Terminus top: " + ", ".join("{}={}".format(k, v) for v, k in top))
|
|
print()
|
|
|
|
|
|
def main():
|
|
both = os.path.exists(TERMINUS)
|
|
if both:
|
|
sys.path.insert(0, TERMINUS)
|
|
else:
|
|
print("Terminus is not installed; reporting vterm alone.\n")
|
|
|
|
burst = 32 * 1024 # what a 30 ms tick collects during a flood
|
|
trickle = 512 # a few rows a frame, as a build log arrives
|
|
|
|
report("A. colour flood, 6 runs/line, 200 colours",
|
|
colour_flood(), burst, both)
|
|
report("B. colour flood, 6 runs/line, 8 colours",
|
|
colour_flood(palette=8), burst, both)
|
|
report("C. colour trickle, a few rows a frame",
|
|
colour_flood(lines=600), trickle, both)
|
|
report("D. plain flood, no colour",
|
|
plain_flood(), burst, both)
|
|
report("E. TUI repaint, 60 full-screen frames, one cell changes",
|
|
tui_repaint(), burst, both)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|