Skip to content

Commit 66540a2

Browse files
measure(profiler): decode/removal/parse split for DELETE - removal 59%, parse 19%, decode 11%
Items 1 and 2 of the agreed sequence, and item 1's diagnosis inverted by the measurement it required. Item 2 - two new stages (Parse, IndexDecode) plus the remaining stamps: - Parse around the batch dispatcher's statement classification: statement parsing is ~1.4us per statement and was invisible, which is why a stage report could look complete while a fifth to a half of the wall time sat in the dispatcher. Also stamped the batch commit (Commit). - IndexDecode, split out of IndexMaintenance, because "is it the decode or the removal?" decides the fix. - IndexMaintenance around the bulk-update path's per-row hash-index Remove/Add. - still uncovered and recorded in the plan: the second batch dispatcher, parser internals, and WalAppend/WalFlush (stages that exist but nothing writes). Item 1 - DELETE attributed with the new split (10K by PK, 50K rows, one batch transaction): TEXT-indexed, plaintext 97,528 ops/s 10.25 us/row TEXT-indexed, at-rest 91,917 ops/s 10.88 us/row INT-indexed, plaintext 193,001 ops/s 5.18 us/row INT-indexed, at-rest 154,004 ops/s 6.49 us/row stages (at-rest): index-maint 58.6% (42.2 ms in SEVEN calls: PK DeleteBulk + one RemoveBatchKeys per loaded index) | parse 19.3% (13.9 ms in 10,000 calls) | index-decode 11.3% | row-locate 7.1% | commit 2.6% | engine-write 1.2%. The decode hypothesis (arena reads to build hash keys) is WRONG: decode is 11%. The cost is the REMOVAL, and a TEXT key costs ~3x an INTEGER key there (42.2 vs 14.4 ms) - string hashing/equality on the key. The tombstone is noise for the third time. And SQL parsing turned out to be 19-45% of a DELETE batch: the same per-statement cost the harness pays for every row of its UPDATE/DELETE phases, which makes a prepared/canonical DELETE path the more promising of the two remaining levers. Gate: SharpCoreDB.slnx 0 errors; SharpCoreDB.Tests 1857 / 0 failed / 16 skipped. One full-suite run showed a single failure in HashIndexPerformanceTests (a timing-ratio test) which passes 2/2 in isolation - recorded as a flake, not a regression. Docs: plan section 7 (the split, the findings, and the honest instrumentation-coverage list).
1 parent b98f241 commit 66540a2

4 files changed

Lines changed: 73 additions & 10 deletions

File tree

docs/performance/INSERT_UPDATE_PERFORMANCE_PLAN.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,11 +1095,35 @@ Stage shares at-rest: **`index-maintenance` 81.9%** (42.2 ms of 10K deletes), `r
10951095
key. So the lever is **not** "make the delete cheaper" but "do the index removal in bulk / deferred", which
10961096
is what `DeferredIndexUpdater` exists for. The tombstone (the part the plan expected to matter) is noise.
10971097

1098-
**Instrumentation coverage (2026-09-14, §2).** Added: `Table.InsertBatch` (Validate — covering validation and
1099-
serialization — plus RowLocate around the batch PK probes; the path had none) and the fixed-width bulk-delete
1100-
fast path (RowLocate / IndexMaintenance / EngineWrite). Still uncovered, recorded honestly: the bulk **UPDATE**
1101-
path's index-maintenance block and the SQL/engine overhead outside the table (parse, plan cache, commit) —
1102-
the last column above is exactly that share, and it is why the totals in a stage report are not wall time.
1098+
**Follow-up (2026-09-14, decode vs removal vs parse).** With `IndexDecode` and `Parse` now separate stages,
1099+
the same workload (10K deletes by PK, 50K rows, one batch transaction) splits as follows — TEXT column
1100+
indexed vs INTEGER column indexed, which isolates the cost of a *variable-length* index key:
1101+
1102+
| arm | ops/s | wall µs/row |
1103+
|---|---:|---:|
1104+
| TEXT indexed, plaintext | 97,528 | 10.25 |
1105+
| TEXT indexed, at-rest | 91,917 | 10.88 |
1106+
| INTEGER indexed, plaintext | 193,001 | 5.18 |
1107+
| INTEGER indexed, at-rest | 154,004 | 6.49 |
1108+
1109+
Stage shares (at-rest): `index-maintenance` **58.6%** (42.2 ms, only **7 calls** — the PK `DeleteBulk` plus one
1110+
`RemoveBatchKeys` per loaded index), `parse` **19.3%** (13.9 ms, **10,000 calls** — 1.4 µs per statement),
1111+
`index-decode` **11.3%**, `row-locate` 7.1%, `commit` 2.6%, `engine-write` **1.2%**. So:
1112+
- the public decode hypothesis was **wrong**: decoding the indexed columns (arena reads, decrypts at rest) is
1113+
11%, not the bulk — the removal itself is, and a TEXT key costs roughly **3× an INTEGER key** there
1114+
(42.2 ms vs 14.4 ms), i.e. string hashing/equality on the key is the expensive part;
1115+
- **SQL statement parsing is 1.4 µs per statement and 19–45% of a DELETE batch** — a share that was completely
1116+
invisible before `Parse` existed, and it is the same cost the `--pk` harness pays for every row of its
1117+
UPDATE/DELETE phases;
1118+
- the tombstone (1.2%) is confirmed as noise for the third time.
1119+
1120+
**Instrumentation coverage (2026-09-14, §2).** Covered now: `Table.InsertBatch` (Validate — validation *and*
1121+
serialization — plus RowLocate around the batch PK probes; the path had none), the fixed-width bulk-delete fast
1122+
path (RowLocate / IndexMaintenance / IndexDecode / EngineWrite), bulk-update per-row hash-index maintenance
1123+
(IndexMaintenance), the batch dispatcher's statement classification (Parse) and the batch commit (Commit).
1124+
Still uncovered, recorded honestly: the *second* batch dispatcher path, parser internals below the dispatcher,
1125+
and `WalAppend`/`WalFlush` — those stages exist in the enum but nothing writes them yet, so a stage report
1126+
still cannot be read as wall time.
11031127

11041128
---
11051129

src/SharpCoreDB/DataStructures/Table.CRUD.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,10 +2209,18 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
22092209

22102210
if (oldRow.TryGetValue(colName, out var oldVal) && oldVal is not null)
22112211
{
2212+
long hashRemoveStart = Diagnostics.WritePathProfiler.Stamp();
22122213
hashIdx.Remove(oldVal, rowPosition);
2214+
Diagnostics.WritePathProfiler.Add(
2215+
Diagnostics.WritePathProfiler.Stage.IndexMaintenance,
2216+
hashRemoveStart);
22132217
}
22142218

2219+
long hashAddStart = Diagnostics.WritePathProfiler.Stamp();
22152220
hashIdx.Add(newVal, rowPosition);
2221+
Diagnostics.WritePathProfiler.Add(
2222+
Diagnostics.WritePathProfiler.Stage.IndexMaintenance,
2223+
hashAddStart);
22162224
}
22172225
}
22182226
}
@@ -4074,8 +4082,9 @@ this.storage is null ||
40744082
// loaded hash-index entry, decoding only the indexed columns from the raw fixed-width records
40754083
// (no full-row deserialization). Variable values resolve through the overflow arena, mirroring
40764084
// the fixed-width codec used by the generic path.
4077-
long deleteIndexStart = WritePathProfiler.Stamp();
4085+
long deletePkIndexStart = WritePathProfiler.Stamp();
40784086
this.Index.DeleteBulk(keys);
4087+
WritePathProfiler.Add(WritePathProfiler.Stage.IndexMaintenance, deletePkIndexStart);
40794088

40804089
var arena = GetOverflowArena();
40814090
foreach (var (colName, hashIdx) in this.hashIndexes)
@@ -4097,6 +4106,10 @@ this.storage is null ||
40974106

40984107
var type = this.ColumnTypes[colIdx];
40994108
var decoded = new object?[count];
4109+
4110+
// §2 split: the DECODE (per row, per index — an arena read per variable value) is measured
4111+
// separately from the REMOVAL, because they have completely different fixes.
4112+
long decodeStart = WritePathProfiler.Stamp();
41004113
for (int i = 0; i < count; i++)
41014114
{
41024115
var payload = raw.AsSpan((int)(i * stride) + 4, layout.FixedSize);
@@ -4119,11 +4132,13 @@ this.storage is null ||
41194132
}
41204133
}
41214134

4135+
WritePathProfiler.Add(WritePathProfiler.Stage.IndexDecode, decodeStart);
4136+
4137+
long removeStart = WritePathProfiler.Stamp();
41224138
hashIdx.RemoveBatchKeys(decoded, positions);
4139+
WritePathProfiler.Add(WritePathProfiler.Stage.IndexMaintenance, removeStart);
41234140
}
41244141

4125-
WritePathProfiler.Add(WritePathProfiler.Stage.IndexMaintenance, deleteIndexStart);
4126-
41274142
if (this.storage is { IsInTransaction: true })
41284143
{
41294144
// Transactional delete: buffer the physical offsets so the in-place marker is applied

src/SharpCoreDB/Database/Execution/Database.Batch.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,11 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string
10211021

10221022
foreach (var sql in statements)
10231023
{
1024+
// §2 instrumentation (2026-09-14): the batch dispatcher's own cost — statement classification and
1025+
// the per-table parse passes below — was invisible, so a stage report looked complete while a
1026+
// large share of the wall time was spent here. Only the classification, not the DML (which the
1027+
// table layer stamps itself), so nothing is double counted.
1028+
long batchParseStart = Diagnostics.WritePathProfiler.Stamp();
10241029
if (IsInsertStatement(sql))
10251030
{
10261031
// ✅ PHASE 3: Fast path — parse directly into column-ordered object[] rows
@@ -1064,6 +1069,8 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string
10641069
{
10651070
nonInserts.Add(sql);
10661071
}
1072+
1073+
Diagnostics.WritePathProfiler.Add(Diagnostics.WritePathProfiler.Stage.Parse, batchParseStart);
10671074
}
10681075

10691076
lock (_walLock)
@@ -1175,8 +1182,10 @@ private bool TryParseDeleteForBatch(string sql, out string tableName, out string
11751182
// Only commit if we started the transaction
11761183
if (!isInTransactionBefore)
11771184
{
1185+
long commitStart = Diagnostics.WritePathProfiler.Stamp();
11781186
storage.CommitSync();
11791187
storage.FlushTransactionBuffer();
1188+
Diagnostics.WritePathProfiler.Add(Diagnostics.WritePathProfiler.Stage.Commit, commitStart);
11801189
}
11811190

11821191
// ✅ FIX: Force tables to refresh row count from disk to ensure visibility

src/SharpCoreDB/Diagnostics/WritePathProfiler.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,31 @@ public enum Stage
5757

5858
/// <summary>Committing a transaction.</summary>
5959
Commit = 8,
60+
61+
/// <summary>
62+
/// Parsing a SQL statement and resolving its execution plan. Instrumented on the batch dispatcher
63+
/// (2026-09-14) because a stage report previously showed only the work *inside* the table, so its
64+
/// total was never wall time — the missing share was parse/plan/dispatch.
65+
/// </summary>
66+
Parse = 9,
67+
68+
/// <summary>
69+
/// Decoding the indexed columns of a row to compute the keys for index maintenance. Separated from
70+
/// <see cref="IndexMaintenance"/> (2026-09-14) because a DELETE measured 82% in that bucket, and the
71+
/// question "is it the decode or the removal?" decides the fix: the decode of a TEXT column resolves
72+
/// an overflow-arena block (and decrypts it at rest), while the removal is a hashed bucket update.
73+
/// </summary>
74+
IndexDecode = 10,
6075
}
6176

62-
private const int StageCount = 9;
77+
private const int StageCount = 11;
6378

6479
private static readonly long[] ElapsedTicks = new long[StageCount];
6580
private static readonly long[] CallCounts = new long[StageCount];
6681
private static readonly string[] StageNames =
6782
[
6883
"validate", "encode", "index-maint", "row-locate", "in-place-patch",
69-
"engine-write", "wal-append", "wal-flush", "commit",
84+
"engine-write", "wal-append", "wal-flush", "commit", "parse", "index-decode",
7085
];
7186

7287
private static int _enabled;

0 commit comments

Comments
 (0)