Files
five/docs/FiveSql2-Optimization-Plan.md
Charles KWON OhJun 486e466592 feat: FiveSql2 43/43, @byref, mutable closure, RTL 479, DateTime fix
Major changes since last commit:
- FiveSql2 SQL:1999 engine (10,458 LOC) — 43/43 ALL PASS
- 21 compiler/runtime bugs fixed (short-circuit AND/OR, FOR LOOP, etc.)
- @byref pass-by-reference via RefCell pattern
- Mutable closure capture (EnsureLocalRef + RefCell sharing)
- RTL: 400 → 479 functions (+79: file, string, datetime, hash, UTF-8)
- DateTime/Timestamp fully working (hb_DateTime, hb_Hour/Min/Sec, display)
- Reserved word guard (39 keywords blocked from function calls)
- AEval arg order fix (element before index)
- Closure capture redecl fix (unique _cap_ names per block)
- Hash/string indexing in ArrayPush/ArrayPop
- Harbour compat test suite: 51/51
- 4 docs: Porting Report, Implementation Plan, Optimization Plan, Commercialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:35:37 +09:00

516 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FiveSql2 최적화 계획서
**Date:** 2026-04-08
**Experts:** SQL Query Optimizer · Go I/O Architect · SQLite Internal Designer
**Baseline:** Home ext4, 100 emp + 200 orders, 1000 iterations
---
## 현재 성능 프로파일
| 쿼리 | us/query | 병목 |
|------|----------|------|
| SELECT * | 2,192 | Resolve per column per row |
| WHERE filter | 2,280 | EvalExpr per row |
| ORDER BY | 3,150 | ASort + SqlCoerceForCmp per comparison |
| GROUP BY HAVING | 4,135 | SqlValToStr + FindColIdx per row per col |
| DISTINCT | 1,019 | RowKey string concat |
| INNER JOIN | 9,291 | Hash join probe + dbGoto per match |
| CTE simple | 8,037 | Temp DBF create/write/read/delete |
| RECURSIVE CTE | 5,585 | In-memory (good) |
| ROW_NUMBER | 3,705 | Partition key + sort |
| RANK PARTITION | 4,748 | Partition key + sort |
| SUM OVER | 2,060 | Partition key |
| INSERT | 4,319 | dbAppend + FieldPut |
| UPDATE | 6,144 | Scan + replace + commit |
| COUNT(*) | 1,065 | Full scan |
| CTE+Win+JOIN | 18,395 | CTE file I/O + JOIN + Window |
---
## Expert 1: SQL Query Optimizer
> *"가장 빠른 행은 읽지 않는 행이다"*
### 1.1 Resolve 캐시 — Column Binding (Impact: ALL queries, -40%)
**현재:** 매 행의 매 컬럼마다 `Resolve()` 호출
```
FetchRow (per row):
FOR i := 1 TO Len(aExprs) // 4 columns
cField := Upper(SubStr(...)) // string op
nWA := ::FindWA(cTblAlias) // linear search in aTables
dbSelectArea(nWA) // workarea switch
nFPos := FieldPos(cField) // field name lookup
xVal := FieldGet(nFPos) // actual read
AllTrim(xVal) // string trim
NEXT
```
100 rows × 4 cols = 400 calls → 400× FindWA + 400× FieldPos + 400× dbSelectArea
**SQLite 방식: Compile-time column binding**
쿼리 실행 전에 각 column reference를 `{nWA, nFieldPos}` 쌍으로 미리 바인딩한다.
SQLite의 `OP_Column` opcode는 cursor + column_index 만 가지고 직접 읽는다.
```
// 구현: RunSelect에서 scan loop 진입 전
LOCAL aBindings := {} // pre-computed {nWA, nFieldPos} per result column
FOR i := 1 TO Len(aResultExprs)
xE := aResultExprs[i][1]
IF xE[1] == ND_COL .AND. xE[2] != "*"
// resolve once → store {nWA, nFPos}
AAdd(aBindings, {nWA, nFPos})
ELSE
AAdd(aBindings, NIL) // complex expr → fallback to EvalExpr
ENDIF
NEXT
// scan loop: use binding directly
WHILE ! Eof()
aRow := {}
FOR i := 1 TO Len(aBindings)
IF aBindings[i] != NIL
dbSelectArea(aBindings[i][1])
AAdd(aRow, AllTrim(FieldGet(aBindings[i][2])))
ELSE
AAdd(aRow, ::EvalExpr(aResultExprs[i][1]))
ENDIF
NEXT
...
ENDDO
```
**WHERE에도 동일 적용:** WHERE expr 안의 ND_COL도 바인딩하면 EvalExpr recursion 제거.
**예상 효과:**
- SELECT *: 2,192 → ~800us (-63%)
- WHERE: 2,280 → ~900us (-60%)
- 전체 쿼리 평균: -40%
### 1.2 GROUP BY — Hash Key 사전 계산 (Impact: GROUP BY, -50%)
**현재:**
```
FOR i := 1 TO Len(aRows) // 100 rows
cKey := ""
FOR j := 1 TO Len(aGroupBy) // 1-3 cols
nGCol := ::FindColIdx(aGroupBy[j], aFN) // O(n) linear search
cKey += SqlValToStr(aRows[i][nGCol]) + "|" // AllTrim + Str
NEXT
NEXT
```
**SQLite 방식: Column index pre-resolve + integer hash**
```
// Pre-resolve group column indices once
aGColIdx := {}
FOR j := 1 TO Len(aGroupBy)
AAdd(aGColIdx, ::FindColIdx(aGroupBy[j], aFN))
NEXT
// Use integer hash instead of string concat
FOR i := 1 TO Len(aRows)
nHash := 0
FOR j := 1 TO Len(aGColIdx)
nHash := nHash * 31 + SqlHashVal(aRows[i][aGColIdx[j]])
NEXT
// Collision check with actual values only on hash match
NEXT
```
### 1.3 Aggregate — SqlCoerceNum 중복 제거 (Impact: GROUP BY, -20%)
**현재 (TSqlAgg:199-200):**
```
IF xMin == NIL .OR. SqlCoerceNum(xVal) < SqlCoerceNum(xMin)
// SqlCoerceNum(xVal) called TWICE per comparison
```
**수정:**
```
nVal := SqlCoerceNum(xVal) // cache once
nSum += nVal
IF xMin == NIL .OR. nVal < nMinCached
xMin := xVal
nMinCached := nVal
ENDIF
```
### 1.4 WHERE Short-Circuit 강화 (Impact: WHERE, -15%)
**현재:** `AND`/`OR`는 short-circuit하지만, 비교 연산자 `=`, `<`, `>` 에서
양쪽 모두 `SqlCoerceForCmp()` (AllTrim + Upper)을 호출한다.
**수정:** 같은 타입이면 coercion 스킵
```
IF ValType(xL) == "N" .AND. ValType(xR) == "N"
RETURN xL < xR // direct comparison, no coercion
ENDIF
IF ValType(xL) == "C" .AND. ValType(xR) == "C"
RETURN xL < xR // Harbour string comparison
ENDIF
// Fallback: coerce
```
---
## Expert 2: Go I/O Architect
> *"시스템 콜 1회 = 유저 코드 10,000줄"*
### 2.1 CTE — Temp DBF 제거, In-Memory Table (Impact: CTE, -80%)
**현재 (MaterializeCTE):**
```
dbCreate("__cte_name.dbf", aStruct) // 1. 파일 생성 (syscall: open+write)
USE __cte_name.dbf NEW // 2. 파일 열기 (syscall: open+read)
FOR each row
dbAppend() // 3. 레코드 추가 (syscall: write)
NEXT
CLOSE // 4. 닫기 (syscall: close)
USE __cte_name.dbf NEW SHARED // 5. 다시 열기 (syscall: open+read)
... query uses it ...
CLOSE // 6. 닫기
FErase("__cte_name.dbf") // 7. 삭제 (syscall: unlink)
```
7번의 syscall이 CTE당 발생. ext4에서도 ~5ms.
**SQLite 방식: Ephemeral Table**
SQLite는 CTE를 `sqlite3_malloc` 기반 B-tree에 저장한다.
Five에서는 in-memory array를 직접 사용:
```
// MaterializeCTE → MaterializeCTEInMemory
aFN := {"COL1", "COL2", ...}
aDataRows := {}
// Execute anchor query, collect rows into aDataRows
// Store in executor context: ::hCTEData[cName] := {aFN, aDataRows}
// No dbCreate, no USE, no FErase
// When referenced:
// Instead of dbSelectArea + FieldGet, use direct array access
// aRow := ::hCTEData["name"][2][nRowIdx]
```
RECURSIVE CTE는 이미 이 방식이다 (TSqlExecutor:2062). Non-recursive도 동일 적용.
**예상 효과:** CTE: 8,037 → ~1,000us (-87%)
### 2.2 dbSelectArea 최소화 (Impact: ALL, -15%)
**현재:** scan loop 내에서 매 행마다 `dbSelectArea(nWA)` 호출
```
WHILE ! Eof()
... process row ...
dbSelectArea(nWA) // ← 매번 호출 (100행 = 100회)
dbSkip()
ENDDO
```
**Go 레벨:** `dbSelectArea`는 WorkAreaManager의 current area를 바꾸는 함수.
JOIN이 없는 단일 테이블 쿼리에서는 불필요.
**수정:**
```
dbSelectArea(nWA) // loop 전 1회
dbGoTop()
WHILE ! Eof()
... process row (no dbSelectArea needed if single table) ...
dbSkip() // same WA, no switch needed
ENDDO
```
JOIN이 있을 때만 `dbSelectArea` 유지.
### 2.3 FieldGet Batch — 행 단위 일괄 읽기 (Impact: SELECT *, -30%)
**현재:** 컬럼마다 `FieldGet(i)` 개별 호출
```
FOR i := 1 TO nCols
dbSelectArea(nWA)
AAdd(aRow, FieldGet(nFPos))
NEXT
```
**Go 레벨 최적화:** `hbrdd/dbf/dbf.go``GetRecord() []Value` 메서드 추가
```go
// dbf.go — 레코드 버퍼에서 모든 필드를 한 번에 추출
func (a *Area) GetRecord() []Value {
fields := make([]Value, a.header.NumFields())
for i := range fields {
fields[i] = a.getFieldFromBuffer(i)
}
return fields
}
```
RTL에서 `dbGetRecord()` 함수로 노출 → FiveSql2의 FetchRow에서 사용.
**예상 효과:** SELECT *: 2,192 → ~1,400us (-36%)
### 2.4 AllTrim 지연 — Lazy Trim (Impact: ALL string ops, -10%)
**현재:** FieldGet 후 즉시 `AllTrim(xVal)` — 매 행 매 컬럼
DBF는 고정 길이 필드이므로 `"Alice "` 같은 공백 패딩이 있다.
하지만 모든 경우에 trim이 필요한 건 아니다:
- 비교: `WHERE name = 'Alice'``"Alice " = "Alice"` Harbour는 `SET EXACT OFF`에서 매칭
- 출력: 최종 결과에서만 trim 필요
**수정:** FetchRow에서 trim 제거, 최종 결과 반환 시에만 trim
---
## Expert 3: SQLite Internal Designer
> *"쿼리 컴파일은 1회, 실행은 N회"*
### 3.1 Prepared Statement Cache (Impact: 반복 쿼리, -60%)
**현재:**`five_SQL()` 호출마다:
```
TSqlLexer():New(cSQL) → Tokenize() // O(SQL길이)
TSqlParser2():New() → Parse() // O(토큰수)
TSqlExecutor():New() → Run() // O(행수)
```
FiveSql2 벤치마크는 동일 쿼리 1000회 반복. 매번 lex+parse = ~300us 낭비.
**SQLite 방식: sqlite3_prepare_v2 + sqlite3_step**
```
// Phase 1: Prepare (1회)
oStmt := five_SQL_Prepare("SELECT * FROM emp WHERE salary > ?")
// Phase 2: Execute (N회)
oStmt:Bind(1, 5000)
aR := oStmt:Execute()
// 내부: token array + parse tree를 캐시
// Execute()는 TSqlExecutor():New(cachedQuery):Run()만 호출
```
**간단한 구현 — Query Plan Cache:**
```
STATIC s_hPlanCache := { => }
METHOD Execute(cSQL) CLASS TFiveSQL
LOCAL hQuery
IF hb_HHasKey(s_hPlanCache, cSQL)
hQuery := s_hPlanCache[cSQL]
ELSE
::oLexer := TSqlLexer():New(cSQL)
::oLexer:Tokenize()
::oParser := TSqlParser2():New(::oLexer:GetTokens(), ::aParams)
hQuery := ::oParser:Parse()
s_hPlanCache[cSQL] := hQuery
ENDIF
::oExec := TSqlExecutor():New(hQuery, ::aParams)
RETURN ::oExec:Run()
```
**주의:** hQuery가 mutable이면 deep clone 필요. 또는 immutable 보장.
**예상 효과:** 반복 쿼리: -300us/query (COUNT: 1,065 → ~765us)
### 3.2 Virtual Table for CTE (Impact: CTE, -90%)
SQLite의 ephemeral table 개념을 확장:
```
┌─────────────────────────────────────────────────────┐
│ 현재: CTE → Temp DBF File │
│ │
│ five_SQL("WITH top AS (...) SELECT * FROM top") │
│ ↓ │
│ dbCreate("__cte_top.dbf") ← DISK I/O │
│ USE __cte_top.dbf ← DISK I/O │
│ ... insert rows ... ← DISK I/O │
│ CLOSE + reopen ← DISK I/O │
│ ... scan via dbGoTop/Skip ← DISK I/O │
│ FErase ← DISK I/O │
│ │
│ 제안: CTE → In-Memory Virtual Table │
│ │
│ ::hCTEData["TOP"] := {aFieldNames, aDataRows} │
│ ↓ │
│ Resolve() checks ::hCTEData first │
│ If CTE alias → direct array access (zero I/O) │
│ No dbCreate, no USE, no FErase │
└─────────────────────────────────────────────────────┘
```
**핵심 변경:**
1. `MaterializeCTE``MaterializeCTEInMemory`: 배열로 저장
2. `Resolve()` → CTE alias 감지 시 `::hCTEData`에서 직접 읽기
3. `FetchRow()` → CTE 테이블이면 배열 인덱싱
### 3.3 Register-Based Evaluation (Impact: 모든 expr, -30%)
**현재:** Stack-based AST walking (tree interpreter)
```
EvalExpr({ND_BIN, ">", {ND_COL, "SALARY"}, {ND_LIT, 5000}})
→ EvalExpr({ND_COL, "SALARY"}) // recursive call 1
→ Resolve("SALARY") // string lookup
→ EvalExpr({ND_LIT, 5000}) // recursive call 2
→ return 5000
→ Compare(7500, 5000)
→ return .T.
```
**SQLite 방식: Compiled bytecode**
```
// Compile phase (1회):
program := {
{OP_COLUMN, 4, 0}, // R0 = field[4] (SALARY, pre-bound)
{OP_INTEGER, 5000, 1}, // R1 = 5000
{OP_GT, 0, 1, 2}, // R2 = R0 > R1
}
// Execute phase (per row):
FOR each instruction
SWITCH op
CASE OP_COLUMN: regs[dst] = FieldGet(field_idx)
CASE OP_INTEGER: regs[dst] = value
CASE OP_GT: regs[dst] = regs[a] > regs[b]
END
NEXT
```
이것은 **Phase 2** 최적화 — 현재 아키텍처에서는 column binding으로 대부분의 효과를 얻을 수 있다.
### 3.4 ORDER BY — Pre-Coerced Sort Key (Impact: ORDER BY, -40%)
**현재 ASort 콜백:**
```
{|a, b| SqlRowCompare(a, b) < 0}
SqlRowCompare:
xA := aRowA[nCol]
xB := aRowB[nCol]
xA := SqlCoerceForCmp(xA) // AllTrim() + Upper() — per comparison
xB := SqlCoerceForCmp(xB) // AllTrim() + Upper() — per comparison
```
100행 정렬 = ~660 comparisons × 2 AllTrim = 1,320 AllTrim calls
**SQLite 방식: Sort key materialization**
```
// Pre-compute sort keys before ASort
FOR i := 1 TO Len(aRows)
aSortKeys[i] := {}
FOR j := 1 TO Len(aOrderBy)
AAdd(aSortKeys[i], SqlCoerceForCmp(aRows[i][nCol]))
NEXT
NEXT
// ASort using pre-computed keys (no AllTrim in comparator)
ASort(aRows,,, {|a, b|
FOR k := 1 TO Len(aSortKeys[...])
IF aSortKeys[idxA][k] < aSortKeys[idxB][k]
RETURN .T.
ENDIF
NEXT
})
```
---
## 구현 우선순위
### Phase A: Quick Wins (각각 1시간 이내, 즉시 효과)
| # | 항목 | 대상 파일 | 예상 효과 |
|---|------|----------|----------|
| A1 | **Query Plan Cache** | TFiveSQL.prg | 반복 쿼리 -300us |
| A2 | **FindColIdx 캐시** | TSqlAgg.prg, TSqlSort.prg | GROUP BY -30% |
| A3 | **SqlCoerceNum 중복 제거** | TSqlAgg.prg:199-200 | Aggregate -20% |
| A4 | **dbSelectArea 제거 (단일 테이블)** | TSqlExecutor.prg:1203 | ALL -15% |
| A5 | **비교 타입 매칭 fast-path** | TSqlExecutor.prg:486-490 | WHERE -15% |
### Phase B: Medium Effort (각각 반나절, 구조 변경)
| # | 항목 | 대상 파일 | 예상 효과 |
|---|------|----------|----------|
| B1 | **Column Binding (pre-resolve)** | TSqlExecutor.prg | ALL -40% |
| B2 | **CTE In-Memory Virtual Table** | TSqlExecutor.prg | CTE -80% |
| B3 | **Sort Key Materialization** | TSqlSort.prg | ORDER BY -40% |
| B4 | **AllTrim lazy (최종만)** | TSqlExecutor.prg | ALL strings -10% |
### Phase C: Deep Optimization (1일+, 아키텍처 변경)
| # | 항목 | 대상 파일 | 예상 효과 |
|---|------|----------|----------|
| C1 | **Register-based expr eval** | TSqlExpr.prg (new) | ALL expr -30% |
| C2 | **GetRecord batch read** | hbrdd/dbf/dbf.go + hbrtl | SELECT * -36% |
| C3 | **RIGHT JOIN hash** | TSqlExecutor.prg | RIGHT JOIN -90% |
| C4 | **Prepared Statement API** | TFiveSQL.prg (new) | API 수준 캐싱 |
---
## 예상 최종 성능 (Phase A+B 완료 시)
| 쿼리 | 현재 (us) | Phase A | Phase A+B | 목표 |
|------|-----------|---------|-----------|------|
| SELECT * | 2,192 | 1,800 | **700** | < 1ms |
| WHERE | 2,280 | 1,900 | **800** | < 1ms |
| ORDER BY | 3,150 | 2,700 | **1,500** | < 2ms |
| GROUP BY | 4,135 | 2,800 | **1,500** | < 2ms |
| DISTINCT | 1,019 | 800 | **500** | < 1ms |
| JOIN | 9,291 | 7,500 | **3,000** | < 5ms |
| CTE | 8,037 | 7,700 | **1,000** | < 2ms |
| Window | 3,705 | 3,000 | **1,500** | < 2ms |
| INSERT | 4,319 | 4,000 | **3,000** | < 4ms |
| COUNT | 1,065 | 765 | **500** | < 1ms |
| Complex | 18,395 | 14,000 | **5,000** | < 8ms |
---
## 검증 기준
1. FiveSql2 test_sql1999: **43/43 ALL PASS** (회귀 없음)
2. FiveSql2 test_basic_sql: **15/15 ALL PASS**
3. 벤치마크: Phase A → 전체 평균 **-25%**, Phase B → 전체 평균 **-60%**
4. Go test: **ALL PASS**
---
## Appendix: 파일별 변경 맵
```
_FiveSql2/src/
├── TFiveSQL.prg ← A1: plan cache
├── TSqlExecutor.prg ← A4: dbSelectArea, B1: column binding, B2: CTE in-mem
├── TSqlExpr.prg ← A5: type fast-path, C1: register eval
├── TSqlAgg.prg ← A2: FindColIdx cache, A3: SqlCoerceNum dedup
├── TSqlSort.prg ← A2: FindColIdx cache, B3: sort key materialization
├── TSqlFunc.prg ← helper optimizations
├── TSqlLexer.prg ← (A1에서 캐시되므로 변경 불필요)
├── TSqlParser2.prg ← (A1에서 캐시되므로 변경 불필요)
└── TSqlDDL.prg ← (DML/DDL, 우선순위 낮음)
hbrdd/dbf/
└── dbf.go ← C2: GetRecord batch read
hbrtl/
└── database.go ← C2: dbGetRecord RTL
```
*"Premature optimization is the root of all evil,
but late optimization is the root of all slowness." — SQLite source comment*