# 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*