Load into the piece table's original store; save and copy without materializing

A loaded file's bytes are adopted as the piece table's original storage
instead of being appended to the add buffer: the first keystroke no
longer reallocates and copies the whole file (140 ms at 200 MB), and the
read buffer is not copied again.

Save writes the pieces in order instead of materializing the buffer (a
second full copy that then stayed resident). Buffer::Bytes() assembles a
copy from the pieces and replaces four hand-rolled copies (swap
snapshots, recovery comparison, Command.cc's per-row join, the tests'
accessor). A single-piece table (an unedited file) is viewed in place;
GetLineView serves a line from its piece when it lies within one, so it
no longer copies the whole buffer after every edit. Code that needed the
whole text contiguous says so with ContentView().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 12:19:59 -07:00
committed by Kyle Isom
parent 306a337516
commit 7dae9cc305
7 changed files with 156 additions and 129 deletions
+53 -59
View File
@@ -99,19 +99,26 @@ Buffer::RefreshOnDiskIdentity()
} }
// The bytes to save, as views of the piece table's storage in order (saving
// never makes a contiguous copy of the buffer).
using ByteChunks = std::vector<std::string_view>;
static bool static bool
write_all_fd(int fd, const char *data, std::size_t len, std::string &err) write_all_fd(int fd, const ByteChunks &chunks, std::string &err)
{ {
std::size_t off = 0; for (const std::string_view chunk: chunks) {
while (off < len) { std::size_t off = 0;
ssize_t n = ::write(fd, data + off, len - off); while (off < chunk.size()) {
if (n < 0) { ssize_t n = ::write(fd, chunk.data() + off, chunk.size() - off);
if (errno == EINTR) if (n < 0) {
continue; if (errno == EINTR)
err = std::string("Write failed: ") + std::strerror(errno); continue;
return false; err = std::string("Write failed: ") + std::strerror(errno);
return false;
}
off += static_cast<std::size_t>(n);
} }
off += static_cast<std::size_t>(n);
} }
return true; return true;
} }
@@ -149,7 +156,7 @@ static const mode_t g_process_umask = [] {
// Rewrite `path` in place (truncate, write, fsync). Used for files with // Rewrite `path` in place (truncate, write, fsync). Used for files with
// several hard links, which a temp-file-and-rename save would split. // several hard links, which a temp-file-and-rename save would split.
static bool static bool
write_in_place(const std::string &path, const char *data, std::size_t len, std::string &err) write_in_place(const std::string &path, const ByteChunks &data, std::string &err)
{ {
int flags = O_WRONLY | O_TRUNC; int flags = O_WRONLY | O_TRUNC;
#ifdef O_CLOEXEC #ifdef O_CLOEXEC
@@ -160,7 +167,7 @@ write_in_place(const std::string &path, const char *data, std::size_t len, std::
err = std::string("Failed to open file for writing: ") + std::strerror(errno); err = std::string("Failed to open file for writing: ") + std::strerror(errno);
return false; return false;
} }
bool ok = write_all_fd(fd, data, len, err); bool ok = write_all_fd(fd, data, err);
if (ok && kte::syscall::Fsync(fd) != 0) { if (ok && kte::syscall::Fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno); err = std::string("fsync failed: ") + std::strerror(errno);
ok = false; ok = false;
@@ -173,7 +180,7 @@ write_in_place(const std::string &path, const char *data, std::size_t len, std::
// Create a new file named from `tmpl` (a mkstemp template, which receives // Create a new file named from `tmpl` (a mkstemp template, which receives
// the chosen name) holding the content, fsynced. // the chosen name) holding the content, fsynced.
static bool static bool
write_new_file(std::string &tmpl, const char *data, std::size_t len, std::string &err, int &create_errno) write_new_file(std::string &tmpl, const ByteChunks &data, std::string &err, int &create_errno)
{ {
create_errno = 0; create_errno = 0;
std::vector<char> name(tmpl.begin(), tmpl.end()); std::vector<char> name(tmpl.begin(), tmpl.end());
@@ -186,7 +193,7 @@ write_new_file(std::string &tmpl, const char *data, std::size_t len, std::string
} }
tmpl.assign(name.data()); tmpl.assign(name.data());
const std::string &path = tmpl; const std::string &path = tmpl;
bool ok = write_all_fd(fd, data, len, err); bool ok = write_all_fd(fd, data, err);
if (ok && kte::syscall::Fsync(fd) != 0) { if (ok && kte::syscall::Fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno); err = std::string("fsync failed: ") + std::strerror(errno);
ok = false; ok = false;
@@ -211,7 +218,7 @@ may_write_in_place_after(int create_errno)
static bool static bool
atomic_write_file(const std::string &path_in, const char *data, std::size_t len, std::string &err) atomic_write_file(const std::string &path_in, const ByteChunks &data, std::string &err)
{ {
// Write through a symlink to its target: renaming over the link itself // Write through a symlink to its target: renaming over the link itself
// would replace the link with a regular file. // would replace the link with a regular file.
@@ -242,12 +249,12 @@ atomic_write_file(const std::string &path_in, const char *data, std::size_t len,
std::string copy = path + ".kte-save.XXXXXX"; std::string copy = path + ".kte-save.XXXXXX";
std::string copy_err; std::string copy_err;
int copy_errno = 0; int copy_errno = 0;
const bool have_copy = write_new_file(copy, data, len, copy_err, copy_errno); const bool have_copy = write_new_file(copy, data, copy_err, copy_errno);
if (!have_copy && !may_write_in_place_after(copy_errno)) { if (!have_copy && !may_write_in_place_after(copy_errno)) {
err = "Save failed, file left unchanged: " + copy_err; err = "Save failed, file left unchanged: " + copy_err;
return false; return false;
} }
if (!write_in_place(path, data, len, err)) { if (!write_in_place(path, data, err)) {
if (have_copy) if (have_copy)
err += " (the new content is in " + copy + ")"; err += " (the new content is in " + copy + ")";
return false; return false;
@@ -287,7 +294,7 @@ atomic_write_file(const std::string &path_in, const char *data, std::size_t len,
// the like: the in-place write would truncate the file and then fail. // the like: the in-place write would truncate the file and then fail.
if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode) && may_write_in_place_after(saved_errno)) { if (fd < 0 && dst_exists && S_ISREG(dst_st.st_mode) && may_write_in_place_after(saved_errno)) {
err.clear(); err.clear();
return write_in_place(path, data, len, err); return write_in_place(path, data, err);
} }
if (fd < 0) { if (fd < 0) {
err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err; err = std::string("Failed to create temp file for save: ") + std::strerror(saved_errno) + err;
@@ -309,7 +316,7 @@ atomic_write_file(const std::string &path_in, const char *data, std::size_t len,
(void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask); (void) kte::syscall::Fchmod(fd, 0666 & ~g_process_umask);
} }
bool ok = write_all_fd(fd, data, len, err); bool ok = write_all_fd(fd, data, err);
// Never retry fsync: after a writeback error Linux may mark the pages // Never retry fsync: after a writeback error Linux may mark the pages
// clean, so a second fsync can succeed although the data was lost, and // clean, so a second fsync can succeed although the data was lost, and
// the rename below would then replace a good file with a bad one. // the rename below would then replace a good file with a bad one.
@@ -654,17 +661,8 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
// Build the new content aside and swap it in only once complete: if the // Build the new content aside and swap it in only once complete: if the
// copy runs out of memory, the buffer keeps its old text (clearing first // copy runs out of memory, the buffer keeps its old text (clearing first
// left an empty buffer that a later save wrote over the file). // left an empty buffer that a later save wrote over the file).
PieceTable fresh; // The bytes become the piece table's original storage, uncopied.
try { content_.AdoptOriginal(std::move(data));
if (!data.empty())
fresh.Append(data.data(), data.size());
std::string().swap(data);
} catch (const std::bad_alloc &) {
err = "File too large to load: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
content_ = std::move(fresh);
rows_cache_dirty_ = true; rows_cache_dirty_ = true;
nrows_ = 0; // not used under PieceTable nrows_ = 0; // not used under PieceTable
filename_ = norm; filename_ = norm;
@@ -699,13 +697,11 @@ Buffer::Save(std::string &err) const
err = "Buffer is not file-backed; use SaveAs()"; err = "Buffer is not file-backed; use SaveAs()";
return false; return false;
} }
const std::size_t sz = content_.Size(); ByteChunks chunks;
const char *data = sz ? content_.Data() : nullptr; content_.ForEachChunk([&](const char *d, std::size_t n) {
if (sz && !data) { chunks.emplace_back(d, n);
err = "Internal error: buffer materialization failed"; });
return false; if (!atomic_write_file(filename_, chunks, err)) {
}
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, filename_); kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
return false; return false;
} }
@@ -739,13 +735,11 @@ Buffer::SaveAs(const std::string &path, std::string &err)
out_path = path; out_path = path;
} }
const std::size_t sz = content_.Size(); ByteChunks chunks;
const char *data = sz ? content_.Data() : nullptr; content_.ForEachChunk([&](const char *d, std::size_t n) {
if (sz && !data) { chunks.emplace_back(d, n);
err = "Internal error: buffer materialization failed"; });
return false; if (!atomic_write_file(out_path, chunks, err)) {
}
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
kte::ErrorHandler::Instance().Error("Buffer", err, out_path); kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
return false; return false;
} }
@@ -793,17 +787,23 @@ Buffer::insert_text(int row, int col, std::string_view text)
// ===== Adapter helpers for PieceTable-backed Buffer ===== // ===== Adapter helpers for PieceTable-backed Buffer =====
std::string
Buffer::Bytes() const
{
std::string out;
out.reserve(content_.Size());
content_.ForEachChunk([&](const char *d, std::size_t n) {
out.append(d, n);
});
return out;
}
std::string_view std::string_view
Buffer::GetLineView(std::size_t row) const Buffer::GetLineView(std::size_t row) const
{ {
// Get byte range for the logical line and return a view into materialized data const auto [start, end] = content_.GetLineRange(row); // [start,end) in bytes
auto range = content_.GetLineRange(row); // [start,end) in bytes return end > start ? content_.View(start, end - start) : std::string_view();
const char *base = content_.Data(); // materializes if needed
if (!base)
return std::string_view();
const std::size_t start = range.first;
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
return std::string_view(base + start, len);
} }
@@ -836,13 +836,7 @@ Buffer::content_LineCount_() const
std::string std::string
Buffer::BytesForTests() const Buffer::BytesForTests() const
{ {
const std::size_t sz = content_.Size(); return Bytes();
if (sz == 0)
return std::string();
const char *data = content_.Data();
if (!data)
return std::string();
return std::string(data, data + sz);
} }
#endif #endif
+18 -3
View File
@@ -29,7 +29,7 @@
* *
* 4. Content access: * 4. Content access:
* - Rows(): Materialized line cache (legacy, being phased out) * - Rows(): Materialized line cache (legacy, being phased out)
* - GetLineView(): Zero-copy line access via string_view (preferred) * - GetLineView(): line access via string_view (zero-copy within a piece)
* - Direct PieceTable access for new editing operations * - Direct PieceTable access for new editing operations
*/ */
#pragma once #pragma once
@@ -311,10 +311,25 @@ public:
} }
// Zero-copy view of a line. Points into the materialized backing store; becomes // View of a line's raw bytes, including its trailing '\n' if any. No copy
// invalid after subsequent edits. Use immediately. // when the line lies within one piece (an unedited stretch); a line that
// straddles an edit materializes the whole buffer, so per-row loops over
// an edited buffer should prefer GetLineString(). Invalid after the next
// edit; views of different lines need not be contiguous.
[[nodiscard]] std::string_view GetLineView(std::size_t row) const; [[nodiscard]] std::string_view GetLineView(std::size_t row) const;
// The whole text as one contiguous view (materializes an edited buffer).
// Invalid after the next edit.
[[nodiscard]] std::string_view ContentView() const
{
return content_.ContentView();
}
// A copy of the whole text, assembled from the pieces (no materialized
// copy is kept).
[[nodiscard]] std::string Bytes() const;
[[nodiscard]] const std::string &Filename() const [[nodiscard]] const std::string &Filename() const
{ {
+4 -24
View File
@@ -638,21 +638,6 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
} }
// The buffer's full text (rows joined by '\n', which reproduces the bytes).
static std::string
buffer_text(const Buffer &buf)
{
std::string out;
const std::size_t nrows = buf.Nrows();
for (std::size_t y = 0; y < nrows; ++y) {
if (y > 0)
out.push_back('\n');
out += buf.GetLineString(y);
}
return out;
}
// Replace the text of rows [y, y + old_text lines) in place with new_text, // Replace the text of rows [y, y + old_text lines) in place with new_text,
// where old_text is those rows joined by '\n' (no trailing newline). Unlike // where old_text is those rows joined by '\n' (no trailing newline). Unlike
// delete_row/insert_row this never adds or removes the newline after the // delete_row/insert_row this never adds or removes the newline after the
@@ -929,16 +914,11 @@ static constexpr std::size_t kSearchCountBytesRegex = std::size_t{4} << 20;
static std::string_view line_text_view(const Buffer &buf, std::size_t y); static std::string_view line_text_view(const Buffer &buf, std::size_t y);
// The whole text as one view into the materialized buffer. // The whole text as one view.
static std::string_view static std::string_view
buffer_text_view(const Buffer &buf) buffer_text_view(const Buffer &buf)
{ {
const std::size_t nrows = buf.Nrows(); return buf.ContentView();
if (nrows == 0)
return {};
const char *base = buf.GetLineView(0).data();
const auto last = buf.GetLineView(nrows - 1);
return {base, static_cast<std::size_t>(last.data() + last.size() - base)};
} }
@@ -3187,7 +3167,7 @@ cmd_newline(CommandContext &ctx)
// Build the replaced text in one pass and apply it as a single // Build the replaced text in one pass and apply it as a single
// edit: one piece-table edit per match cost O(file) each, so // edit: one piece-table edit per match cost O(file) each, so
// replace-all was quadratic (298 s for 249k matches in 12 MB). // replace-all was quadratic (298 s for 249k matches in 12 MB).
const std::string before = buffer_text(*buf); const std::string before = buf->Bytes();
std::string after; std::string after;
after.reserve(before.size()); after.reserve(before.size());
std::size_t pos = 0; std::size_t pos = 0;
@@ -3653,7 +3633,7 @@ cmd_newline(CommandContext &ctx)
} }
}); });
if (changed > 0) if (changed > 0)
replace_buffer_text(*buf, buffer_text(*buf), after, ru); replace_buffer_text(*buf, buf->Bytes(), after, ru);
clamp_cursor_to_buffer(*buf); clamp_cursor_to_buffer(*buf);
buf->SetDirty(true); buf->SetDirty(true);
ctx.editor.SetStatus("Regex replaced in " + std::to_string(changed) + " line(s)" + ctx.editor.SetStatus("Regex replaced in " + std::to_string(changed) + " line(s)" +
+2 -15
View File
@@ -18,19 +18,6 @@ constexpr int kMaxUArgCount = 1000000;
namespace { namespace {
static std::string
buffer_bytes_via_views(const Buffer &b)
{
const std::size_t nrows = b.Nrows();
std::string out;
for (std::size_t i = 0; i < nrows; i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
static void static void
apply_pending_line(Editor &ed, const std::size_t line1) apply_pending_line(Editor &ed, const std::size_t line1)
{ {
@@ -597,11 +584,11 @@ Editor::process_pending_opens_()
Buffer tmp; Buffer tmp;
std::string oerr; std::string oerr;
if (tmp.OpenFromFile(req.path, oerr)) { if (tmp.OpenFromFile(req.path, oerr)) {
const std::string orig = buffer_bytes_via_views(tmp); const std::string orig = tmp.Bytes();
std::string rerr; std::string rerr;
std::uint64_t valid = 0; std::uint64_t valid = 0;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) { if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
std::string rec = buffer_bytes_via_views(tmp); std::string rec = tmp.Bytes();
if (rec == orig) { if (rec == orig) {
// Nothing to recover. Remove the journal rather than // Nothing to recover. Remove the journal rather than
// appending this session's records to it (it may end in a // appending this session's records to it (it may end in a
+47
View File
@@ -185,6 +185,7 @@ void
PieceTable::Clear() PieceTable::Clear()
{ {
pieces_.clear(); pieces_.clear();
std::string().swap(original_);
add_.clear(); add_.clear();
materialized_.clear(); materialized_.clear();
total_size_ = 0; total_size_ = 0;
@@ -198,6 +199,52 @@ PieceTable::Clear()
} }
void
PieceTable::AdoptOriginal(std::string &&bytes)
{
Clear();
std::string().swap(add_); // release a previous content's capacity
std::string().swap(materialized_);
original_ = std::move(bytes);
total_size_ = original_.size();
if (total_size_ > 0)
pieces_.push_back(Piece{Source::Original, 0, total_size_});
}
std::string_view
PieceTable::ContentView() const
{
if (total_size_ == 0)
return {};
if (pieces_.size() == 1) {
const Piece &p = pieces_.front();
return {(p.src == Source::Original ? original_ : add_).data() + p.start, p.len};
}
materialize();
return {materialized_.data(), materialized_.size()};
}
std::string_view
PieceTable::View(std::size_t byte_offset, std::size_t len) const
{
if (byte_offset >= total_size_ || len == 0)
return {};
len = std::min(len, total_size_ - byte_offset);
if (dirty_) {
// Materialized text is stale: serve the range from its piece when it
// fits in one, rather than copying the whole buffer.
auto [idx, inner] = locate(byte_offset);
if (idx < pieces_.size() && inner + len <= pieces_[idx].len) {
const Piece &p = pieces_[idx];
return {(p.src == Source::Original ? original_ : add_).data() + p.start + inner, len};
}
}
return ContentView().substr(byte_offset, len);
}
void void
PieceTable::addPieceBack(const Source src, const std::size_t start, const std::size_t len) PieceTable::addPieceBack(const Source src, const std::size_t start, const std::size_t len)
{ {
+31 -9
View File
@@ -4,7 +4,7 @@
* PieceTable is kte's core text storage data structure. It provides efficient * PieceTable is kte's core text storage data structure. It provides efficient
* insert/delete operations without copying the entire buffer by maintaining a * insert/delete operations without copying the entire buffer by maintaining a
* sequence of "pieces" that reference ranges in two underlying buffers: * sequence of "pieces" that reference ranges in two underlying buffers:
* - original_: Initial file content (currently unused, reserved for future) * - original_: A loaded file's content (AdoptOriginal), never modified
* - add_: All text added during editing * - add_: All text added during editing
* *
* Key advantages: * Key advantages:
@@ -15,7 +15,8 @@
* *
* Performance characteristics: * Performance characteristics:
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth * - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
* - Materialization (Data() call) is O(total_size) but cached until next edit * - Materialization (Data() call) is O(total_size) but cached until next edit;
* a single-piece table (an unedited file) is viewed in place, not copied
* - Line index is lazily rebuilt on first line-based query after edits * - Line index is lazily rebuilt on first line-based query after edits
* - Range and Find operations use lightweight caches for repeated queries * - Range and Find operations use lightweight caches for repeated queries
* *
@@ -83,18 +84,39 @@ public:
// Content management // Content management
void Clear(); void Clear();
// Replace the content with `bytes`, taken over without copying (a loaded
// file). Undo spans into the old storage are invalidated, as by Clear().
void AdoptOriginal(std::string &&bytes);
// Accessors // Accessors
char *Data() // The whole text, contiguous; nullptr when empty. Valid until the next
// modification.
[[nodiscard]] const char *Data() const
{ {
materialize(); const std::string_view v = ContentView();
return materialized_.empty() ? nullptr : materialized_.data(); return v.empty() ? nullptr : v.data();
} }
[[nodiscard]] const char *Data() const // The whole text as one view: in place for a single piece, else the
// materialized copy (built once per modification). Valid until the next
// modification.
[[nodiscard]] std::string_view ContentView() const;
// A view of [byte_offset, byte_offset + len), clamped to the content: in
// place when the range lies within one piece (no copy), else into the
// materialized text. Valid until the next modification.
[[nodiscard]] std::string_view View(std::size_t byte_offset, std::size_t len) const;
// Call fn(const char *data, std::size_t len) for each piece, in order:
// the content without materializing it.
template<typename Fn>
void ForEachChunk(Fn &&fn) const
{ {
const_cast<PieceTable *>(this)->materialize(); for (const Piece &p: pieces_) {
return materialized_.empty() ? nullptr : materialized_.data(); if (p.len > 0)
fn((p.src == Source::Original ? original_ : add_).data() + p.start, p.len);
}
} }
@@ -205,7 +227,7 @@ private:
void RebuildLineIndex() const; void RebuildLineIndex() const;
// Underlying storages // Underlying storages
std::string original_; // unused for builder use-case, but kept for API symmetry std::string original_; // a loaded file's bytes (AdoptOriginal)
std::string add_; std::string add_;
std::vector<Piece> pieces_; std::vector<Piece> pieces_;
+1 -19
View File
@@ -43,24 +43,6 @@ constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
constexpr std::uint32_t VERSION = 2; constexpr std::uint32_t VERSION = 2;
static std::string
snapshot_buffer_bytes(const Buffer &b)
{
const std::size_t nrows = b.Nrows();
std::string out;
// Cheap lower bound: sum of row sizes.
std::size_t approx = 0;
for (std::size_t i = 0; i < nrows; i++)
approx += b.GetLineView(i).size();
out.reserve(approx);
for (std::size_t i = 0; i < nrows; i++) {
auto v = b.GetLineView(i);
out.append(v.data(), v.size());
}
return out;
}
static fs::path static fs::path
xdg_state_home() xdg_state_home()
{ {
@@ -1313,7 +1295,7 @@ SwapManager::RecordCheckpoint(Buffer &buf, const bool urgent_flush)
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::CHKPT; p.type = SwapRecType::CHKPT;
p.urgent_flush = urgent_flush; p.urgent_flush = urgent_flush;
p.chkpt = snapshot_buffer_bytes(buf); p.chkpt = buf.Bytes();
enqueue(std::move(p)); enqueue(std::move(p));
} }