Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite how marks are stored & add reflow #16937

Merged
merged 33 commits into from
Apr 5, 2024
Merged

Conversation

zadjii-msft
Copy link
Member

This is pretty much a huge refactoring of how marks are stored in the buffer.

Gone is the list of ScrollMarks in the buffer that store regions of text as points marking the ends. Those would be nigh impossible to reflow nicely.

Instead, we're going to use TextAttributes to store the kind of output we've got - Prompt, Command, Output, or, the default, None. Those already reflow nicely!

But we also need to store things like, the exit code for the command. That's why we've now added ScrollbarData to ROWs. There's really only going to be one prompt->output on a single row. So, we only need to store one ScrollbarData per-row. When a command ends, we can just go update the mark on the row that started that command.

But iterating over the whole buffer to find the next/previous prompt/command/output region sounds complicated. So, to avoid everyone needing to do some variant of that, we've added MarkExtents (which is literally just the same mark structure as before). TextBuffer can figure out where all the mark regions are, and hand that back to callers. This allows ControlCore to be basically unchanged.

But collecting up all the regions for all the marks sounds expensive! We need to update the scrollbar frequently, we can't just collect those up every time! No we can't! But we also don't need to. The scrollbar doesn't need to know where all the marks start and end and if they have commands and this and that - no. We only need to know the rows that have marks on them. So, we've now also got ScrollMark to represent just a mark on a scrollbar at a specific row on the buffer. We can get those quickly.

src/buffer/out/Marks.hpp Fixed Show fixed Hide fixed
src/buffer/out/textBuffer.cpp Fixed Show fixed Hide fixed
src/buffer/out/textBuffer.cpp Fixed Show fixed Hide fixed

This comment has been minimized.

Copy link
Member

@DHowett DHowett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10/22, but submitting to give you a chance to think about these before I continue

std::pair<til::point, til::point> GetExtent() const
{
til::point realEnd{ til::coalesce_value(outputEnd, commandEnd, end) };
return std::make_pair(til::point{ start }, realEnd);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return std::make_pair(til::point{ start }, realEnd);
return std::make_pair(start, realEnd);

q: is this necessary? start is already the right type


void ROW::EndOutput(std::optional<unsigned int> error) noexcept
{
if (_promptData.has_value())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gut check me - EndOutput may happen on a line without a prompt, right? Do we care about those instances?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, technically, yes. But I think the one caller of this (TextBuffer::EndOutput) goes up and actually does find the right row

@@ -299,6 +305,8 @@ class ROW final
bool _wrapForced = false;
// Occurs when the user runs out of text to support a double byte character and we're forced to the next line
bool _doubleBytePadded = false;

std::optional<ScrollbarData> _promptData = std::nullopt;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lhecker this will grow ROW by 12+ bytes (considering padding for alignment after ScrollbarData::category, and the size of the flag in an optional), for a total of 108k for a full buffer. Is this OK?

@zadjii-msft if you want to save the cost of this in conhost, you can use the preprocessor version of TIL_FEATURE for this and the implementation bodies (fwiw)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else we discussed:

struct ScrollbarData
{
    til::color color;
    uint32_t exitCode = 0;
    MarkCategory category{ MarkCategory::Default };

    bool valid = false;
    bool hasColor = false;
    bool hasExitCode = false;
};

struct ROW {
    // ...
    ScrollbarData _promptData;
};

to avoid some overhead

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I think optional is about as optimal as you can get; it's pretty much a struct { uninitialized<T> _value; bool valid; } already, but with some packing magic to make it so that alignment such-and-such works out

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I'd agree that std::optional is optimal in keeping data uninitialized, I'd find it difficult to agree that it's optimal when it comes to its memory layout. point, color and so on for instance all double in size when wrapped in an optional.

The current ScrollbarData struct is 20 bytes large, whereas the one above is 12 bytes large. Due to ROW's necessary 16 byte alignment this effectively results in 20 bytes of padding per ROW.

MarkExtents' struct size for instance is 60 bytes, compared to just 28 if we didn't use optional. 54% of the struct is just empty space.

@@ -38,6 +38,16 @@ enum class UnderlineStyle
Max = DashedUnderlined
};

// We only need a few bits, but uint8_t is two bytes anyways, and apparently
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// We only need a few bits, but uint8_t is two bytes anyways, and apparently
// We only need a few bits, but due to padding uint8_t occupies two bytes anyways, and apparently

fwiw since uint8_t is unequivocally one byte, we should explain why it's two ^

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna leave it as a u16 unfortunately due to the std::has_unique_object_representations_v thing

src/buffer/out/TextAttribute.hpp Outdated Show resolved Hide resolved
src/buffer/out/textBuffer.hpp Show resolved Hide resolved
src/buffer/out/textBuffer.hpp Outdated Show resolved Hide resolved
@@ -502,7 +502,7 @@ namespace ControlUnitTests
const auto& start = core->_terminal->GetSelectionAnchor();
const auto& end = core->_terminal->GetSelectionEnd();
const til::point expectedStart{ 24, 0 }; // The character after the prompt
const til::point expectedEnd{ 29, 3 }; // x = buffer.right
const til::point expectedEnd{ 21, 3 }; // x = the end of the text
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoa, what happened to cause this change?

@DHowett
Copy link
Member

DHowett commented Apr 3, 2024

Looking at how we're storing marks now, it makes me wonder if we need the FTCS flushing mechanism at all - now that they're TextAttributes, can conpty emit them? Is that ~ ~ weird ~ ~ or wrong?

I'm assuming Leonard wants to say "no, please stop rendering things with conpty" but it's only 2024 😄

@microsoft-github-policy-service microsoft-github-policy-service bot added the Needs-Author-Feedback The original author of the issue/PR needs to come back and respond to something label Apr 4, 2024
src/cascadia/TerminalCore/Terminal.cpp Outdated Show resolved Hide resolved
src/cascadia/TerminalCore/Terminal.cpp Outdated Show resolved Hide resolved
{
// We want to return _no_ marks when we're in the alt buffer, to effectively
// hide them.
return _inAltBuffer() ? std::vector<ScrollMark>{} : std::move(_activeBuffer().GetMarkRows());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Don't (usually) std::move a return value. Otherwise RVO becomes impossible. Same thing below.

src/buffer/out/textBuffer.cpp Outdated Show resolved Hide resolved
Comment on lines 3343 to 3344
// Otherwise, we've changed from any state -> any state, and it doesn't really matter.
if (markKind != MarkKind::None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to the earlier check this will always be true.


if (markKind != MarkKind::None)
{
lastMarkedText = { nextX, y };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me as if we could move this down to the lastMarkKind = markKind; which would IMO be easier to read.

We could also use a switch/case instead of a series of if conditions. An explicit MarkKind::None could then ensure that we don't set the lastMarkKind and lastMarkedText by doing something like...

case MarkKind::None:
    x = nextX;
    continue;

}
lastPromptY = promptY;
}
std::reverse(commands.begin(), commands.end());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can avoid reversing here if you iterate forwards through the buffer instead, right?

@microsoft-github-policy-service microsoft-github-policy-service bot removed the Needs-Author-Feedback The original author of the issue/PR needs to come back and respond to something label Apr 4, 2024
Copy link
Member

@DHowett DHowett left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm comfortable enough with this!

}
void ROW::SetScrollbarData(std::optional<ScrollbarData> data) noexcept
{
_promptData = data;
Copy link
Member

@lhecker lhecker Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _promptData doesn't get reset in Reset(), which is called during buffer circling. Due to this I believe this code will start failing to work once the buffer is full.

If I'm correct, then simply adding it to Reset() should fix it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This is my only remaining important concern. The memory layout is IMO not optimal for perf, but we can noodle on that later.)

Comment on lines 1198 to 1202
_promptData = ScrollbarData{
.category = MarkCategory::Prompt,
.color = std::nullopt,
.exitCode = std::nullopt,
};
Copy link
Member

@lhecker lhecker Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, and I'm writing this just because I think it's interesting, as I mentioned elsewhere, optional is really only good at keeping stuff uninitialized. It's not really consistently great at initializing things, however.

As Dustin said, an optional is basically

template<typename T>
struct optional {
    union { T data }; // union keeps it uninitialized even if it has a constructor
    bool initialized = false;
};

The problem with optional is that its constructor

template < class U = T >
constexpr optional( U&& value );

takes the initial value by reference, std::moves it into data and sets initialized to true. This only works great if the compiler happens to figure out how to optimize it. This doesn't work consistently in any compiler, but MSVC in particular also happens to be really REALLY bad at avoiding unnecessary struct copies.

This means that constructing a ScrollbarData like you do looks like this:

mov     BYTE PTR [rcx], 4            ; .category = 4
mov     BYTE PTR [rcx+8], 0          ; .color.initialized = false
mov     BYTE PTR [rcx+16], 0         ; .exitCode.initialized = false

However, constructing a std::optional<ScrollbarData> for _promptData looks like this:

; $T1 == the current function stack
mov     BYTE PTR $T1[rsp], 4         ; stack: .category = 4
mov     BYTE PTR $T1[rsp+8], 0       ; stack: .color.initialized = false
movups  xmm0, XMMWORD PTR $T1[rsp]   ; stack -> register
mov     BYTE PTR $T1[rsp+16], 0      ; stack: .exitCode.initialized = false
mov     eax, DWORD PTR $T1[rsp+16]   ; stack -> register
movups  XMMWORD PTR [rcx], xmm0      ; register -> ROW (.category/.color)
mov     DWORD PTR [rcx+16], eax      ; register -> ROW (.exitCode)
mov     BYTE PTR [rcx+20], 1         ; ._promptData.initialized = true

In other words, MSVC constructs the ScrollbarData on stack, then copies that into register, and then copies it again into ROW. Other compilers don't do this exact thing, but they're doing similar dumb stuff under similar circumstances.

However, fret not, because the C++ consortium has solved this problem by making improvements to the language like introducing proper union types by adding another overload to std::optional which can be used here:

template< class... Args >
constexpr explicit optional( std::in_place_t, Args&&... args );
std::optional<ScrollbarData>{
    std::in_place,
    MarkCategory::Prompt,
    std::nullopt,
    std::nullopt,
};

Just kidding:

mov     BYTE PTR $T1[rsp], 4
mov     BYTE PTR $T1[rsp+8], 0
movups  xmm0, XMMWORD PTR $T1[rsp]
mov     BYTE PTR $T1[rsp+16], 0
mov     BYTE PTR $T1[rsp+20], 1
movsd   xmm1, QWORD PTR $T1[rsp+16]
movups  XMMWORD PTR [rcx], xmm0
movsd   QWORD PTR [rcx+16], xmm1

This occurs because fixing the construction doesn't fix the existence of its move constructor. That one is still implemented in the STL and thus relies on the compiler optimizing it which no compiler does consistently correct.

The actual proper fix is to use this:

if (!_promptData.has_value())
{
    _promptData.emplace(MarkCategory::Prompt);
}

which results in this:

cmp     BYTE PTR [rcx+20], 0         ; _promptData.has_value()
jne     SHORT $LN8@test              ; if (!...) goto ret;
mov     BYTE PTR [rcx], 4            ; .category = 4
mov     BYTE PTR [rcx+8], 0          ; .color.initialized = false
mov     BYTE PTR [rcx+16], 0         ; .exitCode.initialized = false
mov     BYTE PTR [rcx+20], 1         ; ._promptData.initialized = true
$LN8@test:
ret     0

While using emplace fixes the issue here, my point is mainly that optional is not particularly great for performance nor memory usage. It may work, but only with careful use, as demonstrated above.

Comment on lines 3525 to 3527
auto attr = GetCurrentAttributes();
attr.SetMarkAttributes(MarkKind::Output);
SetCurrentAttributes(attr);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: You can just do

_currentAttributes.SetMarkAttributes(MarkKind::Output);

here and similar above.

Comment on lines 862 to 864
const auto inConpty{ g.getConsoleInformation().IsInVtIoMode() };
const auto resizeQuirk{ g.getConsoleInformation().GetVtIo()->IsResizeQuirkEnabled() };
if (!(inConpty && resizeQuirk))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW this change is not quite the same as it was before as it prevents the compiler from shortcutting the latter call. An alternative way to write this may be this:

if (!g.getConsoleInformation().IsInVtIoMode() ||
    !g.getConsoleInformation().GetVtIo()->IsResizeQuirkEnabled())
{

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah sorry, this was for debugging.

Comment on lines 415 to 419
const auto currentNewPos = _mainBuffer->GetCursor().GetPosition();
if (currentNewPos.y < proposedTop)
{
_mainBuffer->GetCursor().SetPosition(til::point{ currentNewPos.x, proposedTop });
}
Copy link
Member

@lhecker lhecker Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I once read somewhere in our repository that adjusting the viewport to fit the cursor is much better than adjusting the cursor. I'm not sure what the reason was back then, but I can see how a later write should always continue where a previous one left off.

Instead I would suggest moving this up to line 390 and do this (untested):

proposedTop = std::min(proposedTop, newCursorPos.y);

@DHowett DHowett changed the title Enable reflowing marks Rewrite how marks are stored & add reflow Apr 5, 2024
@DHowett DHowett disabled auto-merge April 5, 2024 19:54
@DHowett DHowett enabled auto-merge April 5, 2024 19:54
@DHowett DHowett added this pull request to the merge queue Apr 5, 2024
Merged via the queue into main with commit c3f44f7 Apr 5, 2024
20 checks passed
@DHowett DHowett deleted the dev/migrie/b/15057-reflow-marks branch April 5, 2024 20:45
github-merge-queue bot pushed a commit that referenced this pull request Apr 5, 2024
* Switches the marks feature to being stable. 
* Renames the settings, to remove "experimental."



Stacked on top of #16937, so that we actually finish reflow before
merging this.

Closes #15057
github-merge-queue bot pushed a commit that referenced this pull request May 13, 2024
This is fallout from #16937. 

* Typing a command then backspacing the chars then asking for
suggestions would think the current commandline ended with spaces,
making filtering very hard.
* The currently typed command would _also_ appear in the command
history, which isn't useful.

I actually did TDD for this and wrote the test first, then confirmed
again running through the build script, I wasn't hitting any of the
earlier issues.

Closes #17241
Closes #17243
DHowett pushed a commit that referenced this pull request Jun 7, 2024
This is fallout from #16937.

* Typing a command then backspacing the chars then asking for
suggestions would think the current commandline ended with spaces,
making filtering very hard.
* The currently typed command would _also_ appear in the command
history, which isn't useful.

I actually did TDD for this and wrote the test first, then confirmed
again running through the build script, I wasn't hitting any of the
earlier issues.

Closes #17241
Closes #17243

(cherry picked from commit bf8a647)
Service-Card-Id: 92638413
Service-Version: 1.21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants