Сесия 9: Седмица 3 — JOIN Performance
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled

- Добавен IRJoinStrategy enum (nestedLoop, hash, indexNestedLoop)
- Hash Join: build hash table върху по-малката страна, probe от другата
- Index Nested Loop Join: използва B-Tree индекс за point lookup
- Query planner chooseJoinStrategy избира стратегия според наличие на индекс
- LEFT/RIGHT/FULL JOIN fallback-ват към Nested Loop
- PK индексите се игнорират за INL (само explicit индекси)

- Benchmark: JOIN 10K ~115ms (Hash), ~90ms (Index NL)
- 5 нови теста за join performance и planner избор

Тестове: 316 — 0 failures
Build: 0 warnings
This commit is contained in:
2026-05-17 10:50:12 +03:00
parent 9d71edafd4
commit 6021bfcb10
4 changed files with 380 additions and 42 deletions
+290 -36
View File
@@ -480,6 +480,55 @@ proc parseRowData(valStr: string): Table[string, string] =
proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row]
proc extractJoinEquality*(expr: IRExpr): (string, string) =
## Extract (leftCol, rightCol) from an equality join condition.
## Both operands must be simple field references.
if expr == nil or expr.kind != irekBinary or expr.binOp != irEq:
return ("", "")
if expr.binLeft.kind == irekField and expr.binRight.kind == irekField:
if expr.binLeft.fieldPath.len > 0 and expr.binRight.fieldPath.len > 0:
return (expr.binLeft.fieldPath[^1], expr.binRight.fieldPath[^1])
return ("", "")
proc chooseJoinStrategy(ctx: ExecutionContext, plan: IRPlan) =
## Analyze join condition and pick the best execution strategy.
if plan == nil or plan.kind != irpkJoin:
return
if plan.joinCond == nil:
plan.joinStrategy = irjsNestedLoop
return
let (leftCol, rightCol) = extractJoinEquality(plan.joinCond)
if leftCol.len == 0 or rightCol.len == 0:
plan.joinStrategy = irjsNestedLoop
return
# Check if either side has a B-Tree index on the join column
proc isPkIndex(tableName, colName: string): bool =
if tableName in ctx.tables:
for col in ctx.tables[tableName].columns:
if col.name == colName and col.isPk:
return true
return false
var hasLeftIndex = false
var hasRightIndex = false
if plan.joinLeft != nil and plan.joinLeft.kind == irpkScan:
let idxName = plan.joinLeft.scanTable & "." & leftCol
if idxName in ctx.btrees and not isPkIndex(plan.joinLeft.scanTable, leftCol):
hasLeftIndex = true
if plan.joinRight != nil and plan.joinRight.kind == irpkScan:
let idxName = plan.joinRight.scanTable & "." & rightCol
if idxName in ctx.btrees and not isPkIndex(plan.joinRight.scanTable, rightCol):
hasRightIndex = true
if hasRightIndex:
plan.joinStrategy = irjsIndexNestedLoop
plan.joinHashCol = rightCol
elif hasLeftIndex:
plan.joinStrategy = irjsIndexNestedLoop
plan.joinHashCol = leftCol
else:
plan.joinStrategy = irjsHash
plan.joinHashCol = rightCol
proc parseVectorString*(value: string): seq[float32] =
## Parse a vector string like "[1.0, 2.0, 3.0]" into seq[float32]
result = @[]
@@ -2479,7 +2528,12 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
result.add(padded)
return result
# Non-LATERAL: standard join execution
# Non-LATERAL: choose join strategy
chooseJoinStrategy(ctx, plan)
# Hash join and index nested loop are only safe for INNER JOIN
# (padding logic for outer joins is complex)
if plan.joinKind != irjkInner and plan.joinStrategy in {irjsHash, irjsIndexNestedLoop}:
plan.joinStrategy = irjsNestedLoop
let rightRows = executePlan(ctx, plan.joinRight)
# Collect all unique column names from each side (excluding internal $ keys)
@@ -2502,52 +2556,252 @@ proc executePlan*(ctx: ExecutionContext, plan: IRPlan): seq[Row] =
result.add(mergeRow(l, r, leftAlias, rightAlias))
return result
for l in leftRows:
var matched = false
for r in rightRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if plan.joinCond == nil or evalExpr(plan.joinCond, merged, ctx) == "true":
result.add(merged)
matched = true
if not matched and (plan.joinKind == irjkLeft or plan.joinKind == irjkFull):
var padded = initTable[string, string]()
for k, v in l:
if not k.startsWith("$"):
padded[k] = v
for col in rightCols:
if col notin padded: padded[col] = "\\N"
if leftAlias.len > 0:
for k, v in l:
if not k.startsWith("$"):
padded[leftAlias & "." & k] = v
if rightAlias.len > 0:
for col in rightCols:
padded[rightAlias & "." & col] = "\\N"
result.add(padded)
case plan.joinStrategy
of irjsHash:
# Hash Join: build hash table on the smaller side
let (leftCol, rightCol) = extractJoinEquality(plan.joinCond)
var buildRows, probeRows: seq[Row]
var buildCol, probeCol: string
var buildAlias, probeAlias: string
var buildIsLeft: bool
if plan.joinKind == irjkRight or plan.joinKind == irjkFull:
for r in rightRows:
var found = false
for l in leftRows:
if leftRows.len <= rightRows.len:
buildRows = leftRows
buildCol = leftCol
buildAlias = leftAlias
probeRows = rightRows
probeCol = rightCol
probeAlias = rightAlias
buildIsLeft = true
else:
buildRows = rightRows
buildCol = rightCol
buildAlias = rightAlias
probeRows = leftRows
probeCol = leftCol
probeAlias = leftAlias
buildIsLeft = false
var hashTable = initTable[string, seq[Row]]()
for row in buildRows:
let key = if buildAlias.len > 0 and buildCol in row: row[buildCol]
elif buildCol in row: row[buildCol]
else: ""
if key.len > 0 and key != "\\N":
if key notin hashTable: hashTable[key] = @[]
hashTable[key].add(row)
var matchedProbe = initTable[int, bool]()
for i, prow in probeRows:
let key = if probeAlias.len > 0 and probeCol in prow: prow[probeCol]
elif probeCol in prow: prow[probeCol]
else: ""
if key in hashTable:
matchedProbe[i] = true
for brow in hashTable[key]:
if buildIsLeft:
result.add(mergeRow(brow, prow, leftAlias, rightAlias))
else:
result.add(mergeRow(prow, brow, leftAlias, rightAlias))
# LEFT / RIGHT / FULL padding for non-matching probe rows
if plan.joinKind == irjkLeft or plan.joinKind == irjkFull or plan.joinKind == irjkRight:
for i, prow in probeRows:
if not matchedProbe.getOrDefault(i, false):
var padded = initTable[string, string]()
for k, v in prow:
if not k.startsWith("$"):
padded[k] = v
if buildIsLeft:
# probe is right side
for col in leftCols:
if col notin padded: padded[col] = "\\N"
if probeAlias.len > 0:
for k, v in prow:
if not k.startsWith("$"):
padded[probeAlias & "." & k] = v
if leftAlias.len > 0:
for col in leftCols:
padded[leftAlias & "." & col] = "\\N"
else:
# probe is left side
for col in rightCols:
if col notin padded: padded[col] = "\\N"
if probeAlias.len > 0:
for k, v in prow:
if not k.startsWith("$"):
padded[probeAlias & "." & k] = v
if rightAlias.len > 0:
for col in rightCols:
padded[rightAlias & "." & col] = "\\N"
result.add(padded)
# RIGHT / FULL padding for non-matching build rows
if plan.joinKind == irjkRight or plan.joinKind == irjkFull:
for i, brow in buildRows:
var found = false
for j, prow in probeRows:
let pkey = if probeAlias.len > 0 and probeCol in prow: prow[probeCol]
elif probeCol in prow: prow[probeCol]
else: ""
let bkey = if buildAlias.len > 0 and buildCol in brow: brow[buildCol]
elif buildCol in brow: brow[buildCol]
else: ""
if pkey == bkey and bkey.len > 0:
found = true
break
if not found:
var padded = initTable[string, string]()
for k, v in brow:
if not k.startsWith("$"):
padded[k] = v
if buildIsLeft:
for col in rightCols:
if col notin padded: padded[col] = "\\N"
if leftAlias.len > 0:
for k, v in brow:
if not k.startsWith("$"):
padded[leftAlias & "." & k] = v
if rightAlias.len > 0:
for col in rightCols:
padded[rightAlias & "." & col] = "\\N"
else:
for col in leftCols:
if col notin padded: padded[col] = "\\N"
if rightAlias.len > 0:
for k, v in brow:
if not k.startsWith("$"):
padded[rightAlias & "." & k] = v
if leftAlias.len > 0:
for col in leftCols:
padded[leftAlias & "." & col] = "\\N"
result.add(padded)
of irjsIndexNestedLoop:
let (leftCol, rightCol) = extractJoinEquality(plan.joinCond)
var outerRows: seq[Row]
var outerAlias, innerAlias: string
var outerCol, innerCol: string
var idxName: string
var outerIsLeft: bool
if plan.joinHashCol == rightCol:
outerRows = leftRows
outerAlias = leftAlias
innerAlias = rightAlias
outerCol = leftCol
innerCol = rightCol
idxName = (if plan.joinRight != nil and plan.joinRight.kind == irpkScan: plan.joinRight.scanTable else: "") & "." & rightCol
outerIsLeft = true
else:
outerRows = rightRows
outerAlias = rightAlias
innerAlias = leftAlias
outerCol = rightCol
innerCol = leftCol
idxName = (if plan.joinLeft != nil and plan.joinLeft.kind == irpkScan: plan.joinLeft.scanTable else: "") & "." & leftCol
outerIsLeft = false
if idxName notin ctx.btrees:
# Fallback to nested loop if index disappeared
plan.joinStrategy = irjsNestedLoop
# Fall through to nested loop below
else:
let btree = ctx.btrees[idxName]
var matchedOuter = initTable[int, bool]()
for i, orow in outerRows:
let key = if orow.hasKey(outerCol): orow[outerCol] else: ""
if key.len > 0 and key != "\\N":
let entries = btree.get(key)
if entries.len > 0:
matchedOuter[i] = true
for entry in entries:
let parsed = parseRowData(entry.rowValue)
if outerIsLeft:
result.add(mergeRow(orow, parsed, leftAlias, rightAlias))
else:
result.add(mergeRow(parsed, orow, leftAlias, rightAlias))
elif plan.joinKind == irjkLeft or plan.joinKind == irjkRight or plan.joinKind == irjkFull:
matchedOuter[i] = false
# Padding for non-matching outer rows
if plan.joinKind == irjkLeft or plan.joinKind == irjkRight or plan.joinKind == irjkFull:
for i, orow in outerRows:
if not matchedOuter.getOrDefault(i, false):
var padded = initTable[string, string]()
for k, v in orow:
if not k.startsWith("$"):
padded[k] = v
if outerIsLeft:
for col in rightCols:
if col notin padded: padded[col] = "\\N"
if leftAlias.len > 0:
for k, v in orow:
if not k.startsWith("$"):
padded[leftAlias & "." & k] = v
if rightAlias.len > 0:
for col in rightCols:
padded[rightAlias & "." & col] = "\\N"
else:
for col in leftCols:
if col notin padded: padded[col] = "\\N"
if rightAlias.len > 0:
for k, v in orow:
if not k.startsWith("$"):
padded[rightAlias & "." & k] = v
if leftAlias.len > 0:
for col in leftCols:
padded[leftAlias & "." & col] = "\\N"
result.add(padded)
return result
of irjsNestedLoop:
for l in leftRows:
var matched = false
for r in rightRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if plan.joinCond == nil or evalExpr(plan.joinCond, merged, ctx) == "true":
found = true
break
if not found:
result.add(merged)
matched = true
if not matched and (plan.joinKind == irjkLeft or plan.joinKind == irjkFull):
var padded = initTable[string, string]()
for k, v in r:
for k, v in l:
if not k.startsWith("$"):
padded[k] = v
for col in leftCols:
for col in rightCols:
if col notin padded: padded[col] = "\\N"
if leftAlias.len > 0:
for k, v in l:
if not k.startsWith("$"):
padded[leftAlias & "." & k] = v
if rightAlias.len > 0:
for col in rightCols:
padded[rightAlias & "." & col] = "\\N"
result.add(padded)
if plan.joinKind == irjkRight or plan.joinKind == irjkFull:
for r in rightRows:
var found = false
for l in leftRows:
let merged = mergeRow(l, r, leftAlias, rightAlias)
if plan.joinCond == nil or evalExpr(plan.joinCond, merged, ctx) == "true":
found = true
break
if not found:
var padded = initTable[string, string]()
for k, v in r:
if not k.startsWith("$"):
padded[rightAlias & "." & k] = v
if leftAlias.len > 0:
padded[k] = v
for col in leftCols:
padded[leftAlias & "." & col] = "\\N"
result.add(padded)
if col notin padded: padded[col] = "\\N"
if rightAlias.len > 0:
for k, v in r:
if not k.startsWith("$"):
padded[rightAlias & "." & k] = v
if leftAlias.len > 0:
for col in leftCols:
padded[leftAlias & "." & col] = "\\N"
result.add(padded)
return result
+7
View File
@@ -69,6 +69,11 @@ type
irjkFull
irjkCross
IRJoinStrategy* = enum
irjsNestedLoop
irjsHash
irjsIndexNestedLoop
IRPlanKind* = enum
irpkScan
irpkFilter
@@ -123,6 +128,8 @@ type
joinCond*: IRExpr
joinAlias*: string
joinLateral*: bool
joinStrategy*: IRJoinStrategy
joinHashCol*: string
of irpkSort:
sortSource*: IRPlan
sortExprs*: seq[IRExpr]