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>
16 KiB
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 메서드 추가
// 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 │
└─────────────────────────────────────────────────────┘
핵심 변경:
MaterializeCTE→MaterializeCTEInMemory: 배열로 저장Resolve()→ CTE alias 감지 시::hCTEData에서 직접 읽기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 |
검증 기준
- FiveSql2 test_sql1999: 43/43 ALL PASS (회귀 없음)
- FiveSql2 test_basic_sql: 15/15 ALL PASS
- 벤치마크: Phase A → 전체 평균 -25%, Phase B → 전체 평균 -60%
- 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