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; } } }