using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Runtime;
using Amphora;
using Annulus;
using Azimuth;
using Clave;
using Glass.Editing;
namespace Glass.PerformanceTests
{
///
/// Times what a whole TextBox does with an enormous text: loading it, the frames that count its lines, and then the
/// keys, wheel turns, clicks, drags and compositions of a user, each followed by the frame that settles it. What a
/// GPU draws is left out, but the window's text is baked for it on every frame, so that cost is in.
///
public static class TextBoxBenchmark
{
public const string Argument = "--textbox";
public const string LinesArgument = "--lines";
public const string NoWrapArgument = "--no-wrap";
public const string RightArgument = "--right";
public const string CenterArgument = "--center";
public const string LimitedArgument = "--limited";
public const string GcArgument = "--gc";
public const string CountOnlyArgument = "--count-only";
public const string Usage = "Usage: Glass.PerformanceTests --textbox [text size in chars] "
+ "[--lines | --no-wrap [--right | --center]] [--limited] [--gc ] "
+ "[--count-only]";
public const int ControlIterations = 20000000;
public const int ControlRepeats = 5;
// Read so the control's loop isn't compiled away.
private static uint controlSink;
// The time of a fixed amount of arithmetic, best of a few runs, which says how fast the machine is at the
// moment. A timing taken beside it can be compared with one taken when the machine was busy.
public static double ControlMilliseconds()
{
double best = double.MaxValue;
for (int repeat = 0; repeat < ControlRepeats; ++repeat)
{
long start = Stopwatch.GetTimestamp();
uint hash = 1;
for (int i = 0; i < ControlIterations; ++i)
{
hash = hash * 1664525u + 1013904223u + (uint)i;
}
controlSink += hash;
best = Math.Min(best, (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency);
}
return best;
}
public const int DefaultSize = 100000000;
public const int ViewWidth = 300;
public const int ViewLines = 10;
private sealed class Harness
{
public TextBox Box;
public KeyboardManager Keys;
public UpdateState State;
public void Frame()
{
Box.Update(Duration.Zero, State);
}
public void Press(KeyCode key, params KeyCode[] heldFirst)
{
var tracker = new KeyTracker();
foreach (KeyCode held in heldFirst)
{
tracker.KeyDown(held, KeyCode.None, isRepeat: false);
}
Keys.KeyDown(tracker.KeyDown(key, KeyCode.None, isRepeat: false));
Frame();
}
public void Wheel(Scalar notches, params KeyCode[] heldFirst)
{
var tracker = new KeyTracker();
KeyboardSnapshot held = default;
foreach (KeyCode key in heldFirst)
{
held = tracker.KeyDown(key, KeyCode.None, isRepeat: false);
}
Box.MouseWheel(new MouseState { Position = new Vector(50, 20) }, notches, held, Keys);
Frame();
}
public void Click(Scalar x, Scalar y)
{
var state = new MouseState { Position = new Vector(x, y) };
Box.MouseDown(state, MouseButtonFlags.Left, default, Keys);
Box.MouseUp(state, MouseButtonFlags.Left, default, Keys);
Frame();
}
}
private static TextBox NewBox(bool wrap, Alignment.Horizontal alignment, bool limited)
{
var material = new TextBoxMaterial
{
Scale = default,
Background = default,
InactiveSelection = default,
CompositionThickness = default,
CompositionTarget = default,
CompositionTargetThickness = default,
ScrollBar = default,
HorizontalScrollBar = default,
Text = new TextMaterialStyle
{
Font = SampleFonts.Philosopher18,
HorizontalMargin = 0,
VerticalMargin = 0,
HorizontalAlignment = alignment,
},
Selection = new SolidColorMaterial(),
Caret = new SolidColorMaterial(),
Composition = new SolidColorMaterial(),
CaretWidth = 2,
};
var box = new TextBox(new TextBoxBehavior { Multiline = true, Wrap = wrap, LimitToVisibleLines = limited })
{
TextBoxMaterial = material,
};
box.Bounds = Rect.FromPositionAndSize(Vector.Zero,
new Vector(ViewWidth, ViewLines * SampleFonts.Philosopher18.LineHeight));
return box;
}
private static void Report(string name, Action operation)
{
Console.WriteLine(TypingBenchmark.Format(TypingBenchmark.Measure(name, operation)));
}
private static void Load(TextBox box, string chunk, int size)
{
// A box that limits its text to the lines it shows would cut the load to those lines.
IEditLimit limit = box.Buffer.Limit;
box.Buffer.Limit = null;
var watch = Stopwatch.StartNew();
while (box.Buffer.Length < size)
{
box.Buffer.Replace(box.Buffer.Length, 0, new StringView(chunk));
box.Buffer.ClearHistory();
}
watch.Stop();
box.Buffer.Limit = limit;
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
"--- {0:N0} chars loaded in {1:F2} s, {2:N0} MB held", box.Buffer.Length, watch.Elapsed.TotalSeconds,
GC.GetTotalMemory(forceFullCollection: true) / 1048576));
}
// Runs the frames that count the lines, which is what a user waits through after opening the text.
private static void CountThroughFrames(Harness harness)
{
var frames = new List();
var frameTimes = new List();
long allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
int youngBefore = GC.CollectionCount(0);
int middleBefore = GC.CollectionCount(1);
int fullBefore = GC.CollectionCount(2);
var watch = Stopwatch.StartNew();
harness.Frame();
while (!harness.Box.AllLinesCounted)
{
int collections0 = GC.CollectionCount(0);
int collections2 = GC.CollectionCount(2);
ulong cyclesBefore = IndexBenchmark.ThreadCycles();
long start = Stopwatch.GetTimestamp();
harness.Frame();
double milliseconds = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
frameTimes.Add(new IndexBenchmark.SliceTime
{
Milliseconds = milliseconds,
Cycles = IndexBenchmark.ThreadCycles() - cyclesBefore,
CountedBefore = frames.Count,
Gen0 = GC.CollectionCount(0) - collections0,
Gen2 = GC.CollectionCount(2) - collections2,
});
frames.Add(milliseconds);
}
watch.Stop();
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" the count allocated {0:N0} MB and ran {1} young, {2} middle and {3} full collections",
(GC.GetAllocatedBytesForCurrentThread() - allocatedBefore) / 1048576,
GC.CollectionCount(0) - youngBefore, GC.CollectionCount(1) - middleBefore,
GC.CollectionCount(2) - fullBefore));
IndexBenchmark.ReportSlices(frameTimes, "frames", "frames");
frames.Sort();
double worst = frames.Count == 0 ? 0 : frames[frames.Count - 1];
double typical = frames.Count == 0 ? 0 : frames[frames.Count / 2];
double slowTail = frames.Count == 0 ? 0 : frames[frames.Count * 99 / 100];
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" counted in {0:F2} s over {1:N0} frames: median {2:F2} ms, 99th {3:F2} ms, worst {4:F2} ms",
watch.Elapsed.TotalSeconds, frames.Count, typical, slowTail, worst));
}
public const int DragSteps = 40;
// Narrows the box a pixel a frame and widens it again, with the caret where it is, as a drag of its edge does.
private static void MeasureResizeDrag(TextBox box, Harness harness, int caret)
{
box.Buffer.MoveTo(caret, extend: false);
harness.Frame();
var frames = new List();
for (int step = 0; step < DragSteps * 2; ++step)
{
int width = ViewWidth - Math.Min(step, DragSteps * 2 - step);
box.Bounds = Rect.FromPositionAndSize(Vector.Zero,
new Vector(width, ViewLines * SampleFonts.Philosopher18.LineHeight));
long start = Stopwatch.GetTimestamp();
harness.Frame();
frames.Add((Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency);
}
double first = frames[0];
frames.Sort();
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" drag-resize, caret at {0:N0}: {1} frames, median {2:F2} ms, worst {3:F2} ms, first {4:F2} ms; "
+ "lines counted afterwards: {5}",
caret, frames.Count, frames[frames.Count / 2], frames[frames.Count - 1], first, box.AllLinesCounted));
CountThroughFrames(harness);
}
public static int Run(string[] args)
{
int size = DefaultSize;
bool lines = false;
bool noWrap = false;
bool sizeGiven = false;
bool limited = false;
bool countOnly = false;
GCLatencyMode? latencyMode = null;
Alignment.Horizontal alignment = Alignment.Horizontal.Left;
for (int i = 1; i < args.Length; ++i)
{
if (args[i] == LinesArgument)
{
lines = true;
}
else if (args[i] == CountOnlyArgument)
{
countOnly = true;
}
else if (args[i] == GcArgument && i + 1 < args.Length
&& Enum.TryParse(args[i + 1], out GCLatencyMode mode))
{
latencyMode = mode;
++i;
}
else if (args[i] == LimitedArgument)
{
limited = true;
}
else if (args[i] == NoWrapArgument)
{
noWrap = true;
}
else if (args[i] == RightArgument)
{
alignment = Alignment.Horizontal.Right;
}
else if (args[i] == CenterArgument)
{
alignment = Alignment.Horizontal.Center;
}
else if (!sizeGiven
&& int.TryParse(args[i], NumberStyles.None, CultureInfo.InvariantCulture, out size) && size >= 1)
{
sizeGiven = true;
}
else
{
Console.Error.WriteLine(Usage);
return 1;
}
}
if ((lines && noWrap) || (alignment != Alignment.Horizontal.Left && !noWrap))
{
Console.Error.WriteLine(Usage);
return 1;
}
string bitness = Environment.Is64BitProcess ? "x64" : "x86";
string kind = "one hard line";
if (lines)
{
kind = "many lines";
}
else if (noWrap)
{
kind = "one hard line that doesn't wrap";
}
Console.WriteLine($"TextBox, {bitness}, {kind}, {ViewWidth} wide, {ViewLines} lines tall.");
string chunk = lines
? TypingBenchmark.TextOfAbout(PagedBenchmark.ChunkSize)
: TypingBenchmark.OneLineOfAbout(PagedBenchmark.ChunkSize);
TextBox box = NewBox(wrap: !noWrap, alignment, limited);
var harness = new Harness { Box = box, Keys = new KeyboardManager(), State = new UpdateState() };
harness.Keys.Controls.Add(box);
harness.Keys.TrySetFocus(box);
harness.State.Focused = box;
Load(box, chunk, size);
// Loading leaves the caret at the end, and a first frame there would count all the text in one go.
box.Buffer.MoveTo(0, extend: false);
if (latencyMode is GCLatencyMode requested)
{
GCSettings.LatencyMode = requested;
}
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" collector: {0}, latency mode {1}, machine control {2:F2} ms",
GCSettings.IsServerGC ? "server" : "workstation", GCSettings.LatencyMode, ControlMilliseconds()));
CountThroughFrames(harness);
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, " machine control afterwards {0:F2} ms",
ControlMilliseconds()));
if (countOnly)
{
// Each recount follows the old counts being dropped, which is what a drag-resize leaves behind.
MeasureResizeDrag(box, harness, caret: 0);
MeasureResizeDrag(box, harness, caret: 0);
return 0;
}
int middle = box.Buffer.Length / 2;
Report("frame, nothing changed", harness.Frame);
Report("caret to the middle", () =>
{
box.Buffer.MoveTo(middle, extend: false);
harness.Frame();
});
Report("caret to the end", () =>
{
box.Buffer.MoveTo(box.Buffer.Length, extend: false);
harness.Frame();
});
Report("caret to the start", () =>
{
box.Buffer.MoveTo(0, extend: false);
harness.Frame();
});
box.Buffer.MoveTo(middle, extend: false);
harness.Frame();
Report("type a key", () =>
{
box.TextInput("x", harness.Keys);
harness.Frame();
});
Report("backspace", () => harness.Press(KeyCode.Backspace));
Report("type + backspace", () =>
{
box.TextInput("x", harness.Keys);
harness.Frame();
harness.Press(KeyCode.Backspace);
});
Report("undo + redo", () =>
{
box.TextInput("x", harness.Keys);
harness.Frame();
box.Buffer.Undo();
harness.Frame();
box.Buffer.Redo();
harness.Frame();
box.Buffer.Undo();
harness.Frame();
});
Report("arrow right", () => harness.Press(KeyCode.ArrowRight));
Report("arrow down", () => harness.Press(KeyCode.ArrowDown));
Report("page down", () => harness.Press(KeyCode.PageDown));
Report("page up", () => harness.Press(KeyCode.PageUp));
Report("end", () => harness.Press(KeyCode.End));
Report("home", () => harness.Press(KeyCode.Home));
Report("ctrl+right (a word)", () => harness.Press(KeyCode.ArrowRight, KeyCode.ControlLeft));
Report("shift+arrow (select)", () =>
{
harness.Press(KeyCode.ArrowRight, KeyCode.ShiftLeft);
harness.Press(KeyCode.ArrowLeft, KeyCode.ShiftLeft);
});
Report("wheel down + up", () =>
{
harness.Wheel(-1);
harness.Wheel(1);
});
if (noWrap)
{
Report("shift+wheel along", () =>
{
harness.Wheel(-1, KeyCode.ShiftLeft);
harness.Wheel(1, KeyCode.ShiftLeft);
});
}
Report("click", () => harness.Click(120, 30));
Report("double-click's word", () =>
{
box.Buffer.SelectUnitAt(middle, TextUnit.Word);
harness.Frame();
});
Report("select all", () =>
{
box.Buffer.SelectAll();
harness.Frame();
box.Buffer.MoveTo(middle, extend: false);
harness.Frame();
});
Report("drag a selection", () =>
{
var down = new MouseState { Position = new Vector(40, 10) };
var moved = new MouseState { Position = new Vector(260, 80) };
box.MouseDown(down, MouseButtonFlags.Left, default, harness.Keys);
box.MouseMove(moved, default, harness.Keys);
box.MouseUp(moved, MouseButtonFlags.Left, default, harness.Keys);
harness.Frame();
});
box.Buffer.MoveTo(middle, extend: false);
harness.Frame();
string composed = "composing";
Report("composition, a key", () =>
{
box.CompositionUpdate(composed, composed.Length, 0, composed.Length);
harness.Frame();
composed = composed.Substring(1) + composed[0];
});
box.CompositionEnd();
harness.Frame();
Report("scroll to a far line", () =>
{
box.ScrollToLine(box.Buffer.Source.HardLineCount / 2);
box.ScrollToLine(box.Buffer.Source.HardLineCount / 3);
});
if (noWrap)
{
Report("scroll along to 1/3 and 2/3", () =>
{
box.ScrollToX(box.ContentWidth / 3);
box.ScrollToX(box.ContentWidth / 3 * 2);
});
}
foreach (int caret in new[] { 0, middle, box.Buffer.Length })
{
MeasureResizeDrag(box, harness, caret);
}
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "--- {0:N0} MB held before clearing",
GC.GetTotalMemory(forceFullCollection: true) / 1048576));
box.Buffer.UndoBudget = 0;
var clearing = Stopwatch.StartNew();
box.Buffer.Replace(0, box.Buffer.Length, new StringView(""));
harness.Frame();
clearing.Stop();
Console.WriteLine(string.Format(CultureInfo.InvariantCulture,
" clear all the text, no history, and the frame: {0:F2} ms", clearing.Elapsed.TotalMilliseconds));
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "--- {0:N0} MB held after clearing",
GC.GetTotalMemory(forceFullCollection: true) / 1048576));
GC.KeepAlive(box);
return 0;
}
}
}