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>
This commit is contained in:
2026-04-11 11:35:37 +09:00
parent d451b836a6
commit 486e466592
129 changed files with 35248 additions and 241 deletions

View File

@@ -0,0 +1,515 @@
# 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*