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

16 KiB
Raw Blame History

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.goGetRecord() []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                      │
└─────────────────────────────────────────────────────┘

핵심 변경:

  1. MaterializeCTEMaterializeCTEInMemory: 배열로 저장
  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