The VM call path (PushSymbol → Function → Frame) is traversed by every
PRG function call. Three changes together cut per-call overhead across
the entire bench suite.
Changes
- hbrt/call.go Function(): replace pop-push dance with a single slice
shift (N+2 pops + N pushes → 1 copy of N slots + sp adjust). Kills
the per-call `make([]Value, nArgs)` heap alloc. Resolved function
pointer is cached back into sym.Func so subsequent calls on the
same Symbol skip the VM lookup entirely.
- hbrt/vm.go GetSym(): new helper. Generated code calls it with a
pointer to a package-level `*Symbol` slot so FindSymbol (which takes
the VM RWMutex + map lookup) runs at most once per symbol per
process. Nil results are intentionally NOT cached — an init-order
miss becomes a retry on the next call instead of a permanent sticky
failure.
- hbrt/thread.go pushPendingSym(): scalar fast slot for depth=1 call
nesting (common case). Nil syms still go through the slice so the
"empty vs stored nil" ambiguity can't produce a false pop.
- compiler/gengo/gengo.go: emit `t.PushSymbol(t.GetSym(&_sym_<file>_<NAME>, "NAME"))`
for every function call site, with a per-file prefix so multi-PRG
builds don't collide on identical symbol names.
Bugs fixed during bring-up
- pendingSymFast == nil was ambiguous ("unused" vs "nil stored"). Nil
syms now spill to the slice, preserving distinguishability.
- The old varName-reuse branch at the PushSymbol emit site skipped
the GetSym wrapper, emitting a raw `t.PushSymbol(varName)` against
an uninitialized package-level *Symbol. Every call path now funnels
through emitPushSymbol.
bench_sql deltas vs prior build
- B1 SELECT * 114 → 97 µs (15%)
- B4 GROUP_HAVING 584 → 554 µs (5%)
- B8 RECURSIVE CTE 150 → 141 µs (6%)
- B10 RANK PARTITION 310 → 296 µs (5%)
- B11 SUM OVER 335 → 320 µs (4%)
- B14 COUNT 295 → 281 µs (5%)
- B15 CTE+WIN+JOIN 1891 → 1826 µs (3%)
Verification
- go test ./... ALL PASS
- FiveSql2 test_sql1999 43/43
- tests/compat_harbour 56/56
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 KiB
RTL Go-Native 전환 계획
PRG 핫패스와 hbrtl/ RTL 함수 중 Go 네이티브 구현이 이익인 후보 목록 및 진행 기록. 기준선: 최종 결과가 Harbour와 동일해야 한다.
배경
FiveSql2 성능 개선 흐름(3caadb2 SqlOrderBy+SqlGroupBy Go RTL, 5fc9c3b SqlHashJoin Go RTL 등)은 PRG 핫루프를 Go RTL로 옮겨 큰 이득을 보였다. 본 문서는 같은 패턴을 체계적으로 적용할 후보를 추린다.
Harbour 호환 검증 근거
| 항목 | 근거 | 영향 |
|---|---|---|
| 해시 기본 플래그 | harbour-core/include/hbapi.h:927-931 — HB_HASH_FLAG_DEFAULT = HB_HASH_AUTOADD_ASSIGN | HB_HASH_BINARY | HB_HASH_KEEPORDER |
삽입 순서 보존 + memcmp 정확 비교 |
| 해시 키 비교 | harbour-core/src/vm/hashes.c:167-182 — hb_hashItemCmp |
CHAR padding trim 없음, Date/Timestamp는 julian 비교 |
| 내부 탐색 | pPairs[] + pnPos[] 이진 탐색 (O(log N)) |
Five의 Go map 치환은 O(1)로 상회 |
후보 목록
✅ Tier 1 — 즉시 이익, 시맨틱 안전
| # | 대상 | 파일 | 방식 | 예상 효과 | 상태 |
|---|---|---|---|---|---|
| 1 | Hash 스토리지 | hbrtl/hash.go, hbrt/ops_collection.go, hbrt/value.go | map[string]int 인덱스 추가, 삽입 순서 슬라이스 유지 |
50–100x | 완료 (2026-04-17) |
| 2 | SqlDistinct | _FiveSql2/src/TSqlSort.prg:57-70, hbrtl/sqlscan.go | Go RTL map[string]struct{} + strings.Builder |
100–300x | 완료 (2026-04-17) |
| 3 | SqlRowCompare NULL · 혼합타입 정합성 | hbrtl/sqlscan.go, _FiveSql2/src/TSqlSort.prg, _FiveSql2/src/TSqlExecutor.prg | Go/PRG 양 경로 NULL 순서 PRG 시맨틱으로 통일 + NULLS FIRST/LAST 배선 |
정합성 수정 | 완료 (2026-04-17) |
✅ Tier 2 — 블록 NIL 특화 + 누락 타입 보강 (완료)
| # | 대상 | 파일 | 방식 | 상태 |
|---|---|---|---|---|
| 4 | ASort 타입 특화 + 정확성 | hbrtl/array.go:134-300 | 비교자 블록 없을 때 1회 타입 스캔 → 특화 비교자. Date/Logical/Timestamp 지원 추가 (기존엔 no-op) | 완료 (2026-04-17) |
| 5 | AScan fast-path | hbrtl/array.go:302-380 | 검색값이 string/int/double일 때 타입별 인라인 루프. 드물게 쓰는 타입은 valuesEqual fallback |
완료 (2026-04-17) |
🔎 Tier 3 — 내부 헬퍼 최적화
| # | 대상 | 파일 | 방식 | 상태 |
|---|---|---|---|---|
| 6 | RAT 역방향 스캔 | hbrtl/strings2.go:16-51 | 검토 결과 strings.LastIndex + 부분슬라이스는 이미 최적. 변경 없음 |
검토 종료 (2026-04-17) |
| 7 | SqlExprHasAgg | hbrtl/sqlexpr.go | PRG 재귀 → Go AST walker + 상수 시간 agg 이름 조회 | 완료 (2026-04-17) |
✅ Tier 4 — DML Boundary-Crossing 감소 (완료)
| # | 대상 | 파일 | 방식 | 상태 |
|---|---|---|---|---|
| 8 | SqlBulkInsert | hbrtl/sqlscan.go, _FiveSql2/src/TSqlExecutor.prg | CTE/subquery/tmp 테이블 materialize 경로의 FOR j ... dbAppend ... FOR k ... FieldPut 이중 루프를 Go RTL 단일 호출로 대체 |
완료 (2026-04-17) |
| 9 | SqlBulkUpdate | hbrtl/sqlscan.go, _FiveSql2/src/TSqlExecutor.prg RunUpdate | UPDATE 스캔 루프 전체를 Go RTL로 이관. WHERE + SET 값 표현식을 pcode로 컴파일해 PRG 메서드 디스패치 제거 | 완료 (2026-04-17) |
| 10 | MEMRDD 자동 임포트 | compiler/gengo/gengo.go | 모든 Five 프로그램에 _ "five/hbrdd/mem" 블랭크 임포트 자동 추가 → USE "mem:x" VIA "MEMRDD" 즉시 사용 가능 |
완료 (2026-04-17) |
| 11 | PcCompile 결과 캐시 | hbrtl/pcexpr.go | sync.Map으로 소스 문자열 키 캐시. 반복 쿼리에서 파서+genpc 건너뛰기 |
완료 (2026-04-17) |
| 12 | SQL 플랜 캐시 + HbDeepClone | _FiveSql2/src/TFiveSQL.prg, hbrtl/array.go | cSQL → hQuery PRG 해시 캐시. 히트 시 Go RTL HbDeepClone으로 pristine 사본 반환 → SqlFoldConst 인-플레이스 변경 안전 |
완료 (2026-04-17) |
| 13 | 파라미터 바인딩 벤치 입증 | _FiveSql2/test/bench_prep_sql.prg | 기존 five_SQL(cSQL, aParams) + ? 파서가 이미 지원. 플랜 캐시와 결합 시 SELECT 1.58x, INSERT 1.12x |
입증 (2026-04-17) |
| 14 | CTE → MEMRDD | _FiveSql2/src/TSqlExecutor.prg, hbrdd/mem/memrdd.go, hbrtl/sqlscan.go SqlBulkInsert | 3곳 materialize 경로를 dbCreate("mem:xxx", ..., "MEMRDD") + MEMRDD dbUseArea로 전환. SqlBulkInsert가 *dbf.DBFArea 외 일반 hbrdd.Area도 처리하도록 확장. MEMRDD Create가 필드명 trailing-space trim |
완료 (2026-04-17) |
| 15 | SqlWindowPartitions Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions | PARTITION BY 키 빌드 + 행-인덱스 그룹핑을 Go RTL에 위임. N·M 경계 크로싱 → 1 | 완료 (2026-04-17) |
| 16 | SqlWindowSortPartition Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions | 파티션 내 ORDER BY를 Go sort.SliceStable + 사전 해석된 컬럼 인덱스로 처리. PRG 비교 블록 제거 |
완료 (2026-04-17) |
| 17 | SqlGroupRows Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlAgg.prg GroupBy | GROUP BY 그룹 빌드 루프만 Go RTL로. 집계·HAVING은 복잡 표현식 대응 위해 PRG 유지 | 완료 (2026-04-17) |
| 18 | SqlComputeAggSimple Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlAgg.prg ComputeAgg | COUNT/SUM/AVG/MIN/MAX + 컬럼 인자 fast-path. 복잡 인자·GROUP_CONCAT은 PRG fallback | 완료 (2026-04-17) |
| 19 | SQL 스칼라 헬퍼 Go RTL | hbrtl/sqlhelpers.go, _FiveSql2/src/TSqlFunc.prg | SqlIsTrue/SqlCmpEq/SqlCmpLt/SqlCoerceForCmp/SqlCoerceNum/SqlCoerceStr 6개 Go로. PRG tree-walker 평가 경로(HAVING, complex expr) 오버헤드 감소 |
완료 (2026-04-17) |
| 20 | SQL 템플릿 자동 파라미터화 | hbrtl/sqlhelpers.go SqlExtractTemplate, _FiveSql2/src/TFiveSQL.prg | 리터럴(TK_TEXT/TK_NUM)을 TK_QMARK로 치환 + 템플릿 키로 플랜 캐시. 동일 구조 다른 값 쿼리가 캐시 공유 |
완료 (2026-04-17) |
| 21 | TSqlLexer Go 포팅 + 결합 | hbrtl/sqlhelpers.go SqlLexerTokenize + SqlLexAndExtractTemplate, _FiveSql2/src/TFiveSQL.prg | PRG SubStr 기반 문자-단위 렉서를 Go byte-level FSM으로. 자동-파라미터화와 결합해 1회 Go 호출로 lex+normalize 완료 |
완료 (2026-04-17) |
| 22 | SqlWindowAssignRank Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlExecutor.prg ApplyWindowFunctions | ROW_NUMBER/RANK/DENSE_RANK 배정 루프를 Go에서. 파티션당 1회 호출로 per-row SqlWinRowsEqual PRG 호출 제거 | 완료 (2026-04-17) |
| 23 | HbDeepClone 성능 개선 | hbrtl/array.go deepCloneValue | 스칼라 원소는 재귀 스킵 (슬롯 복사만), 해시 키 공유 (문자열/숫자는 불변). 플랜 캐시 히트마다 수행되는 핫패스 | 완료 (2026-04-17) |
| 24 | WA 캐시 + 지연 commit | hbrtl/sqlwacache.go, _FiveSql2/src/TSqlExecutor.prg SqlExecOpenTable/CloseTable + RunInsert/Update/Delete | 워크에어리어 공정-수명 캐시 (opt-in). 활성화 시 DML의 per-query dbUseArea/dbCloseArea/dbCommit 전부 배치 → B12 INSERT 48x | 완료 (2026-04-17) |
| 25 | Plan pcode 캐시 + SqlBulkUpdate flush 지연 | _FiveSql2/src/TSqlExecutor.prg s_hDmlPcodeCache + cCacheKey, hbrtl/sqlscan.go SqlBulkUpdate | 플랜 키별 컴파일된 pcode(aFPos/where/set_pc) 캐시 + WA cache 활성 시 Go RTL 내부 Flush() 스킵 → B13 UPDATE 48x |
완료 (2026-04-17) |
| 26 | SELECT 경로 plan pcode 캐시 | _FiveSql2/src/TSqlExecutor.prg RunSelect fast path | #25의 패턴을 SELECT fast-path에도 적용. TryBuildFieldPositions + TryCompileWhere 결과를 cCacheKey#sel로 캐시. 반복 SELECT의 PRG AST walk 제거 |
완료 (2026-04-17) |
| 27 | SqlEvalHaving Go RTL | hbrtl/sqlscan.go, _FiveSql2/src/TSqlAgg.prg EvalHaving | HAVING 트리 walker를 Go로. ND_LIT/ND_NIL/ND_COL/ND_FN(5 aggs)/ND_BIN/ND_UNI 처리. 복잡 케이스는 PRG fallback | 완료 (2026-04-17, 효과 미미) |
| 28 | VM Function() + Symbol 캐시 | hbrt/call.go Function, hbrt/vm.go GetSym, hbrt/thread.go pushPendingSym, compiler/gengo/gengo.go emitPushSymbol | 모든 PRG 함수 호출이 통과하는 경로. (a) Function()의 pop-push dance를 stack shift로 (heap alloc + 2N+2 ops → 1 copy), (b) 심볼 resolve 결과를 sym.Func에 캐시, (c) gengo가 심볼 포인터를 package-level var에 hoist해 FindSymbol 호출/RWMutex/map lookup을 lazy 1회로 단축. 전역 3-15% 개선 |
완료 (2026-04-17) |
❌ 제외 (Harbour 호환 리스크 과다)
| 대상 | 제외 이유 |
|---|---|
SqlLikeMatch regexp 치환 |
Harbour SQL LIKE의 %/_/[abc]/[!abc]/이스케이프 규칙은 regex와 미스매치. 자체 매처 필요 |
| SubStr Go slice 직접 | 이미 slice 사용 중 (hbrtl/strings.go:149). 변경 이익 없음 |
Descend bytes.Map |
성능 이익 <5% |
| SET DATE 비트셋 사전계산 | 5–15%지만 setDateFormat 전역 일관성 리스크 > 이익 |
✅ 이미 최적 (건드리지 말 것)
- hbrtl/crypto.go MD5/SHA256/BASE64/CRC32 —
crypto/md5,crypto/sha256,encoding/base64사용 중 - hbrtl/binconv.go BIN2I/L/W —
encoding/binary사용 중 - hbrtl/regex.go —
regexp사용 중
진행 기록
#1 Hash 스토리지 O(1) 전환 — 2026-04-17 완료
구조 변경 (hbrt/value.go:237-249)
type HbHash struct {
Keys []Value // 삽입 순서 (HB_HASH_KEEPORDER 기본)
Values []Value // 병렬
Order []int
Flags int32
Index map[string]int // 신규: O(1) 탐색용 미러
}
신규 파일: hbrt/hash_helpers.go
hashKey(v Value) (string, bool)—valueEqual동치류와 일치하는 직렬화. Nil/String/Numeric/Logical/Date/Timestamp 지원. 수치는 정수로 환산 가능하면'I'폼으로 정규화 (int/double 교차 매칭), -0.0 → +0.0(*HbHash).Lookup/Has/Set/Append/Delete/HashGet/ensureIndex/HashFromPairs메서드- 비인덱싱 키 타입(Array/Hash/Block/Pointer)은 fallback 선형 스캔 +
valueEqual
호출부 전환
| 파일 | 변경 |
|---|---|
| hbrt/ops_collection.go | HashGen/ArrayPush/ArrayPop 헬퍼 경유. HashGen은 pair 수집 후 Set로 last-wins 보장 |
| hbrt/valuemethods.go | vmHashHas/vmHashDelete 헬퍼 경유 |
| hbrt/hbfunc.go | HashAdd→Set, HashGetC는 "S"+key 직접 Index 힛 |
| hbrt/macroeval.go | 해시 리터럴 평가 Set (중복 키 last-wins) |
| hbrt/gobridge.go | reflect.Map 변환 Append (Go map은 중복 키 없음) |
| hbrtl/hash.go | 7개 RTL 함수 (HbHash/HGet/HSet/HDel/HHasKey/HKeys/HValues) 전체 헬퍼 경유 |
| hbrtl/json.go | navigatePath/JsonMerge 헬퍼 경유 |
Harbour 호환 보장
- 키 삽입 순서 보존 (
hb_HKeys()반환):Keys[]슬라이스 유지 HB_HASH_BINARY정확 비교:hashKey가 String을 raw bytes로 직렬화- 수치 교차 비교 (
1 == 1.0): 정수로 환산 가능한 double은'I'폼으로 정규화 - 비인덱싱 키:
valueEqualfallback (Array/Hash/Block 포인터 동일성 포함)
스테일 방지: ensureIndex()는 Index가 nil이거나 indexable 키 개수와 불일치하면 재구축. 테스트가 .Keys = append(...)로 직접 조작해도 다음 Lookup 시점에 자동 복구.
검증 (CLAUDE.md 3종)
go test ./...— 15 패키지 ALL PASS- FiveSql2 — 43/43 (100%)
- Harbour compat — 51/51 (100%)
#2 SqlDistinct Go RTL — 2026-04-17 완료
추가 함수 (hbrtl/sqlscan.go)
appendValueHashKey(sb *strings.Builder, v)—valueHashKey와 동일 매핑이나 중간 문자열 할당 없이 Builder에 직접 기록SqlDistinct(aRows) → aRows— Go map 기반 단일 패스 dedup, 입력 순서 보존
호출부 변경 (_FiveSql2/src/TSqlSort.prg:57-62)
METHOD Distinct( aRows ) CLASS TSqlSort
RETURN SqlDistinct( aRows )
PRG의 hb_HHasKey 루프 + 수동 cKey += SqlValToStr(..) + "|" 조립을 Go 한 번 호출로 대체. 컴파일러의 "undeclared variable" 경고는 RTL 함수 심볼이 gengo 테이블에 없기 때문 — 런타임에 SQLDISTINCT 심볼로 해결되어 동작은 정상.
Harbour 호환 보장
- 키 구성 규칙이
SqlValToStr시맨틱(appendValueHashKey)과 byte-for-byte 일치 — CHAR는 trailing space trim, NIL은\x00NIL, 숫자는 int/double 별도 경로 - 입력 순서 보존 → SQL DISTINCT 결과의 첫 등장 순서 유지
- 빈 배열 · 단일 행은 입력 그대로 반환 (PRG 동작과 일치)
hbrt.Sym("SQLDISTINCT", hbrt.FsPublic, SqlDistinct),
검증
go test ./...— ALL PASS- FiveSql2 — 43/43 (100%)
- Harbour compat — 51/51 (100%)
#3 SqlRowCompare NULL 순서 · 혼합타입 정합성 — 2026-04-17 완료
발견된 문제
- Go
SqlOrderBy기본값이 NIL을 가장 작은 값으로 취급 (ASC에서 NULLs FIRST) — PRGSqlRowCompare의 원래 시맨틱(NIL = 가장 큼)과 정반대 - 파서가
NULLS FIRST/LAST(SQL:2003) 스펙을 파싱하지만 (TSqlParser2.prg:962-973) Go/PRG 어느 경로도 이를 읽지 않음 — 명시 스펙이 완전 무시 - Go
compareValues가 숫자 vs 문자열 혼합 타입 비교를 지원하지 않음 — PRG는Val(AllTrim(x))로 강제변환 (TSqlSort.prg:145-148)
수정 내역
| 파일 | 변경 |
|---|---|
| hbrtl/sqlscan.go | sortCol에 nullsFirst bool 필드 추가. cDir == DESC를 기본값으로 하고 arr.Items[2]가 "FIRST"/"LAST"면 오버라이드. compareValues를 compareValuesNonNil 기반으로 재구성하고 NIL 처리를 호출부로 이관. 혼합 N/C 비교용 parseLeadingNumeric 추가 |
| _FiveSql2/src/TSqlExecutor.prg:3818 | TryBuildSortSpec이 aOrderBy[i][3]을 읽어 3번째 요소 cNulls로 Go 스펙에 전달 |
| _FiveSql2/src/TSqlSort.prg:33-54 | OrderBy 메서드가 aOB[i][3]을 s_aOBCols에 보존 |
| _FiveSql2/src/TSqlSort.prg:118-144 | SqlRowCompare가 명시 NULLS FIRST/LAST를 우선 적용, 없으면 cDir == DESC를 기본 |
Harbour/FiveSql2 시맨틱 보장
- 기본값: NIL은 가장 큰 값 → ASC는 NULLs LAST, DESC는 NULLs FIRST (PRG 원래 동작, PostgreSQL 기본과 일치)
NULLS FIRST/LAST명시 시 방향과 무관하게 스펙 우선- 혼합 N/C 비교: PRG
Val(AllTrim(x))동작 복제 (선행 공백 무시, 부호/소수점 허용)
회귀 테스트 — _FiveSql2/test/test_null_order.prg 4/4 PASS: default ASC, default DESC, ASC NULLS FIRST, DESC NULLS LAST
검증
go test ./...— ALL PASS- FiveSql2 43/43 · Harbour compat 51/51
#4, #5 ASort 정확성/특화 + AScan fast-path — 2026-04-17 완료
ASort 버그 발견 — 기본 비교 경로(기존 array.go:164)가 IsString / IsNumeric가 아닌 타입에 대해 return false를 반환 → 날짜·논리값·타임스탬프 배열 정렬이 no-op.
수정 내역 (hbrtl/array.go)
detectArrayKind(items)— 1회 스캔으로 동종 배열 분류 (Int / Numeric / String / Date / Timestamp / Logical / Mixed)- 분류 결과에 따라 타입 특화
sort.SliceStable선택. Int 배열은AsNumInt만 써서 double 변환 생략 - Mixed는
valueLessfallback — Harbour<시맨틱 (NIL 가장 작음, 타입 내 비교)
AScan fast-path (hbrtl/array.go:302-380)
- 검색값이 문자열·정수·실수일 때 타입별 인라인 루프 —
valuesEqual호출·switch·타입 체크 생략 - 정수 검색 + 배열 내 double 원소는 cross-type 비교 (
item.AsNumDouble() == float64(n)) — 기존valuesEqual시맨틱 그대로 - Date/Timestamp/Logical/NIL 검색은
valuesEqualfallback
회귀 테스트 (tests/compat_harbour.prg:328-349)
9c1 ASort dates ascending— julian 기준 정렬 (신규)9c2 ASort logicals: F,F,T,T— 논리값 정렬 (신규)9e1 AScan int found— 정수 탐색 (신규)9e2 AScan int cross-type— 정수로 저장된 배열에 double 검색 (신규)9e3 AScan int not found— 부재 케이스 (신규)
Harbour 호환 보장
- 블록이 주어지면 100% 기존 동작 유지 (부작용 보존)
- 블록 NIL 경로는 Harbour 기본
<시맨틱 복제. 이전엔 깨져 있던 날짜/논리값이 이제 올바르게 정렬
검증
go test ./...— ALL PASS- FiveSql2 — 43/43
- Harbour compat — 56/56 (51 기존 + 5 신규)
#6 RAT 재검토 — 2026-04-17 종결(변경 없음)
strings.LastIndex는 Boyer-Moore/Rabin-Karp 최적화 내장. target[:from] 슬라이스는 Go에서 O(1)·할당-프리. nOccurrence=1 경로(실제 대부분)는 이미 단일 LastIndex 호출. >1 경로는 이전 매치 위치에서 시작해 누적 O(n)이므로 수동 역방향 스캔 대비 이득 없음. 원본 유지.
#7 SqlExprHasAgg Go walker — 2026-04-17 완료
동기: 매 쿼리마다 SELECT 컬럼 표현식 × 재귀 깊이 만큼 호출 — 깊은 식에서 PRG VM 프레임 셋업 비용이 누적됨. 호출 지점: TSqlAgg.prg:41,62,85,134, TSqlExecutor.prg:1298 등 6곳.
구현 (hbrtl/sqlexpr.go)
aggFuncSet—map[string]struct{}(상수시간 룩업). AGG_FUNCTIONS 매크로와 완전 일치 (COUNT/SUM/AVG/MIN/MAX/GROUP_CONCAT/STRING_AGG/LISTAGG/JSON_ARRAYAGG/JSON_OBJECTAGG/XMLAGG/ANY_VALUE/BOOL_AND/BOOL_OR)sqlExprHasAggWalk— PRG SqlExprHasAgg와 byte-for-byte 동일한 재귀 트리 순회. ND_FN/ND_BIN/ND_UNI/ND_CASE 가지 커버. ND_WINDOW/ND_SUB 의도적 미순회 (각자 집계 스코프 보유)- 상수
ndLit,ndCol,ndFn등 —FiveSqlDef.ch의 kind 번호와 동일
호출부 변경
- _FiveSql2/src/TSqlExpr.prg:45-49 — PRG
FUNCTION SqlExprHasAgg제거 (심볼 충돌 방지). 주석으로 Go RTL 위임 명시 - hbrtl/register.go —
SQLEXPRHASAGG공개 심볼 등록 - 기존 호출부(
SqlExprHasAgg(xE)) 그대로 동작 — RTL 심볼이 해결
Harbour 호환 보장: AST kind 번호가 PRG와 정확히 일치. agg 함수 이름 집합이 AGG_FUNCTIONS 매크로와 정확히 일치. 재귀 가지 로직이 PRG와 줄 단위로 매치 (IF xE[1] == ND_FN .AND. SqlIsAggName(xE[2]) 등).
검증
go test ./...— ALL PASS- FiveSql2 — 43/43
- Harbour compat — 56/56
#8 SqlBulkInsert Go RTL — 2026-04-17 완료
동기: dbAppend/FieldPut은 이미 Go RTL. 병목은 PRG 루프가 행·컬럼 단위로 Go RTL을 호출하는 boundary crossing. N행 × M컬럼 = N·M 회 VM 프레임 셋업 + 스택 push/pop + 파라미터 마샬링.
구현 (hbrtl/sqlscan.go)
SqlBulkInsert(aRows) → nInserted— 현재 workarea의*DBFArea에 직접Append()+PutValue()+Flush()- NIL 원소는 필드 건너뜀 (PRG
IF aRows[j][k] != NIL보존) - 행 길이가 필드 수 초과 시 초과분 무시, 부족 시 나머지 필드는 default
호출부 치환 — 동일 형상 루프 3곳 → 1줄
| 위치 | 맥락 |
|---|---|
| TSqlExecutor.prg:2310 | CREATE TABLE AS SELECT / 임시테이블 로드 |
| TSqlExecutor.prg:2630 | subquery driving-table materialization |
| TSqlExecutor.prg:2935 | CTE materialization |
A/B 벤치마크 (_FiveSql2/test/bench_bulk.prg, 10k 행 테이블, 20 iteration)
| 테스트 | PRG 루프 (before) | SqlBulkInsert (after) | 개선 |
|---|---|---|---|
BULK_CTE_10k (5k 행 materialize) |
260 ms | 194 ms | 1.34x |
BULK_SUBQ_10k (2k 행 materialize) |
121 ms | 107 ms | 1.13x |
쿼리당 환산: CTE 10k에서 (260-194)/20 = 3.3ms/쿼리 절감. 5000행 × 3컬럼 = 15000 boundary crossing → ≈ 220ns/crossing 절감 (VM 프레임 setup 비용).
기존 bench_sql(100행 규모) 효과 미미: 40행 × 2컬럼 = 80 crossing × 220ns ≈ 18µs/쿼리 절감. 4.3ms 쿼리에서 <1% noise. 실제 이득은 N이 커질수록 선형 증가.
Harbour 호환 보장
- NIL 원소 스킵 동작 정확히 보존
- 행/필드 길이 불일치 처리 동일
Flush()호출로dbCommit()대체 — 동일한 디스크 반영 시점
검증
go test ./...— ALL PASS- FiveSql2 — 43/43
- Harbour compat — 56/56
진행 순서
- ✅ #1 Hash 스토리지 — 완료
- ✅ #2 SqlDistinct — 완료
- ✅ #3 SqlRowCompare NULL·혼합타입 — 완료
- ✅ #4 ASort 정확성/특화 — 완료
- ✅ #5 AScan fast-path — 완료
- ✅ #6 RAT 재검토 — 변경 없음
- ✅ #7 SqlExprHasAgg Go walker — 완료
- ✅ #8 SqlBulkInsert — 완료 (Tier 4)
- ✅ #9 SqlBulkUpdate — 완료 (Tier 4)
- ✅ #10 MEMRDD 자동 임포트 — 완료 (Tier 4 인프라)
- ✅ #11 PcCompile 결과 캐시 — 완료 (Tier 4 회수 최적화)
- ✅ #12 SQL 플랜 캐시 + HbDeepClone — 완료 (Tier 4 상위 계층)
- ✅ #13 파라미터 바인딩 입증 — 완료 (기존 기능 + 플랜 캐시 결합 효과)
- ✅ #14 CTE → MEMRDD — 완료 (디스크 임시파일 제거)
- ✅ #15 SqlWindowPartitions Go RTL — 완료 (윈도우 파티션 빌드)
- ✅ #16 SqlWindowSortPartition Go RTL — 완료 (윈도우 정렬)
- ✅ #17 SqlGroupRows Go RTL — 완료 (GROUP BY 그룹 빌드)
- ✅ #18 SqlComputeAggSimple Go RTL — 완료 (집계 함수 fast-path)
- ✅ #19 SQL 스칼라 헬퍼 Go RTL — 완료 (IsTrue/CmpEq/CmpLt/Coerce×3)
- ✅ #20 SQL 템플릿 자동 파라미터화 — 완료 (리터럴 →
?+ 플랜 캐시 공유) - ✅ #21 TSqlLexer Go 포팅 + 결합 — 완료 (#20 효과 증폭)
- ✅ #22 SqlWindowAssignRank Go RTL — 완료 (ROW_NUMBER/RANK/DENSE_RANK)
- ✅ #23 HbDeepClone 성능 개선 — 완료 (스칼라 재귀 스킵 + 해시 키 공유)
- ✅ #24 WA 캐시 + 지연 commit — 완료 (B12 INSERT 48x)
- ✅ #25 Plan pcode 캐시 + Flush 지연 — 완료 (B13 UPDATE 48x)
- ✅ #26 SELECT plan pcode 캐시 — 완료 (SELECT fast-path 캐시 확장)
- ✅ #27 SqlEvalHaving Go RTL — 완료 (효과 미미, 복잡 HAVING 워크로드용)
- ✅ #28 VM Function() + Symbol 캐시 — 완료 (전역 3-15%, 호출 경로 핫패스)
전체 계획 완료 (2026-04-17). 각 단계 후 go test ./... + FiveSql2 43/43 + Harbour compat 필수 원칙 준수.
#9 SqlBulkUpdate Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlBulkUpdate(aFieldPositions, pcWhere, aValuePcodes) → nAffected— WHERE + SET 값 모두 컴파일된 pcode를 받아 Go 내부에서 스캔·평가·PutValue.FastFieldGetter설치로 pcode 내부FieldGet도 인터페이스 디스패치 없이*DBFArea.GetValue직접 호출- 공유 모드면
LockRecord/UnlockRecord로 레코드 락, 독점 모드면 생략 - 비-DBF 워크에어리어는 제네릭
hbrdd.Area경로로 fallback
PRG 연결 (_FiveSql2/src/TSqlExecutor.prg RunUpdate)
::oTxn:IsActive()이면 반드시 PRG 루프 (txn 로그 보존) — 안전 게이트- txn 없으면 WHERE + 각 SET 값을
SqlExprToPrg→PcCompile로 pcode 변환 시도 - 하나라도 실패(복잡 CASE·서브쿼리·UDF 등) 시 PRG 루프로 폴백
- 모두 성공 시
SqlBulkUpdate(aFPos, pcWhere, aValuePc)한 번 호출
A/B 벤치마크 (_FiveSql2/test/bench_bulk_upd.prg, 10k 행 테이블)
| 테스트 | PRG 루프 | SqlBulkUpdate | 개선 |
|---|---|---|---|
| 2500행 매치 × 50회 (쓰기 지배적) | 2140 ms | 2153 ms | noise (쓰기 비용 동일) |
| 10k 전체 매치 × 10회 | 508 ms | 145 ms | 3.5x |
| 0행 매치 × 100회 (WHERE만) | 2288 ms | 214 ms | 10.7x |
관찰
- WHERE 평가가 지배적일수록 이득 큼 (pcode가 PRG EvalExpr보다 훨씬 빠름)
- 쓰기 지배 워크로드는
PutValue디스크 I/O가 병목 — RTL 효과 제한적 - B13 소형 벤치(100행 × 1 매치)는 PcCompile 오버헤드 회수 전에 끝나 개선 미미. 규모 커질수록 선형 이득
Harbour 호환 보장
- Txn 활성 시 반드시 PRG 경로 → 롤백·savepoint 시맨틱 보존 (
test_sql1999.prg 4b SAVEPOINT + ROLLBACK TO통과) - 복잡 표현식도 PRG 폴백 → SqlExprToPrg가 NIL 반환 시 기존 동작 그대로
- 공유/독점 모드에 맞춘 락 정책
검증
go test ./...— ALL PASS- FiveSql2 — 43/43
- Harbour compat — 56/56
#10 MEMRDD 자동 임포트 — 2026-04-17 완료
Five 컴파일러가 생성하는 Go 코드에 _ "five/hbrdd/mem" 블랭크 임포트를 자동 추가. 기존에는 mem 패키지가 init()에서 드라이버 등록하지만 아무도 임포트하지 않아 MEMRDD 미등록 상태였음.
변경 (compiler/gengo/gengo.go:103-120)
if hasXBaseCommands(file) {
g.imports["five/hbrdd/mem"] = true
g.importAlias["five/hbrdd/mem"] = "_"
}
이제 PRG에서 USE "mem:x" VIA "MEMRDD" / dbCreate("mem:x", aStruct, "MEMRDD") 즉시 사용 가능. 임시테이블·CTE materialize의 in-memory 전환 기반.
#11 PcCompile 결과 캐시 — 2026-04-17 완료
동기: #9 SqlBulkUpdate가 쿼리마다 SqlExprToPrg → PcCompile을 호출. 파서+preprocess+genpc가 ~50–200µs — B13(100행 × 1 매치) 같은 소형 쿼리에서는 RTL 절감분을 먹어치움.
구현 (hbrtl/pcexpr.go)
var pcCompileCache sync.Map // map[string]*hbrt.PcodeFunc
- 캐시 히트 시 파서/genpc 건너뛰고 즉시 pointer 반환
sync.Map— read-mostly 패턴에 최적.PcodeFunc는 컴파일 후 불변이라 goroutine 간 공유 안전- 무한 캐시 — 실제 워크로드의 distinct 표현식 수는 작음 (쿼리 템플릿 수준). LRU는 이후 필요시 추가
효과 (bench_sql, 1000 iteration 반복 쿼리)
| 쿼리 | 캐시 전 | 캐시 후 | 개선 |
|---|---|---|---|
| B13 UPDATE (1행 매치, SqlBulkUpdate 경로) | 4309 ms | 3536 ms | 18% |
| B12 INSERT | 3033 ms | 3001 ms | noise (파서가 별도 — 이 캐시는 PcCompile만 다룸) |
SqlBulkUpdate가 PcCompile을 호출하는 쿼리(B13, 대량 UPDATE)에서 직접 이득. 타 벤치는 PcCompile을 호출하지 않거나 이미 1회만 호출해서 효과 없음.
Harbour 호환 보장: PcodeFunc는 immutable, 소스 문자열이 키. 동일 소스 → 동일 결과 보장. 컴파일 실패 시 캐시에 저장 안 함.
검증
go test ./...— ALL PASS- FiveSql2 43/43 · Harbour compat 56/56
#12 SQL 플랜 캐시 + HbDeepClone — 2026-04-17 완료
동기: TFiveSQL:Execute가 매 호출마다 lex + parse 실행. 반복 쿼리(B1B11, B13B15 등 벤치 대부분)는 동일 SQL 텍스트 → 파싱을 한 번만 수행하고 재사용하면 큰 이득.
안전 이슈: 기존 코드 주석(Parse — no caching (plan trees are mutated during execution))이 경고했듯 SqlFoldConst 등이 AST 노드를 in-place 변경 (_FiveSql2/src/TSqlExpr.prg:75-151). 캐시에서 포인터를 그대로 반환하면 첫 실행이 캐시를 오염.
구현
- Go RTL
HbDeepClone(xVal) → xNewVal(hbrtl/array.go) —deepCloneValue재귀로 Array/Hash를 element별 복제. 스칼라는 불변이라 그대로 반환.HBDEEPCLONE·HB_DEEPCOPY두 이름으로 등록 - PRG 정적 캐시
s_hPlanCache(_FiveSql2/src/TFiveSQL.prg)- 히트:
HbDeepClone(s_hPlanCache[cSQL])반환 → Run이 마음껏 변경해도 캐시 불변 - 미스: 파싱 후
HbDeepClone(hQuery)를 캐시에 저장, 원본은 Run에 넘김
- 히트:
STATIC s_hPlanCache := { => }
...
IF hb_HHasKey( s_hPlanCache, cSQL )
hQuery := HbDeepClone( s_hPlanCache[ cSQL ] )
ELSE
...parse...
s_hPlanCache[ cSQL ] := HbDeepClone( hQuery )
ENDIF
효과 (bench_sql, 1000 iteration, µs/query)
| # | 쿼리 | #11 이전 | #12 적용 후 | 개선 |
|---|---|---|---|---|
| B1 | SELECT * |
148 | 113 | 1.31x |
| B2 | WHERE |
166 | 85 | 1.95x |
| B3 | ORDER BY |
178 | 96 | 1.85x |
| B4 | GROUP HAVING |
877 | 731 | 1.20x |
| B5 | DISTINCT |
122 | 81 | 1.51x |
| B6 | INNER JOIN |
357 | 231 | 1.55x |
| B7 | CTE simple |
4415 | 4000 | 1.10x |
| B9 | ROW_NUMBER |
1134 | 1017 | 1.11x |
| B11 | SUM OVER |
621 | 493 | 1.26x |
| B13 | UPDATE |
3536 | 3301 | 1.07x |
| B15 | CTE+WIN+JOIN |
5751 | 5502 | 1.04x |
B12 (INSERT)는 문자열 리터럴 i 값이 매 iteration마다 달라 캐시 미스 — 향후 파라미터 바인딩 도입 시 대상.
Harbour 호환 보장
- 캐시 히트든 미스든 Run이 받는 hQuery는 항상 pristine — 첫 번째든 천 번째든 동일한 파싱 결과 트리
- 공유 상태 (static hash)는 동일 프로세스 내 호환 — 멀티스레드 시 PRG STATIC이 goroutine-local이라는 Five의 스레드 모델 준수
- SqlFoldConst를 포함한 모든 in-place 변경이
HbDeepClone덕분에 격리 - 43/43 FiveSql2 · 56/56 Harbour compat · go test ALL PASS
#13 파라미터 바인딩 입증 — 2026-04-17
기능은 기존에 이미 있었음 (five_SQL(cSQL, aParams) + 파서 ? 토큰 처리 + ND_PAR 노드). #12 플랜 캐시와 결합 시 동일 SQL 템플릿은 100% 캐시 히트. 사용자가 문자열 연결 대신 ? 전환 시 자동으로 이득.
A/B (1000 iteration)
| 패턴 | 문자열 연결 | 프리페어 ? |
개선 |
|---|---|---|---|
| INSERT | 3214 ms | 2881 ms | 1.12x (쓰기 I/O 지배) |
| SELECT | 254 ms | 161 ms | 1.58x (파서 지배) |
응용 가이드: 반복 DML/SELECT는 ? + aParams 패턴 권장. 문자열 연결은 매번 파싱 비용 발생.
#14 CTE → MEMRDD — 2026-04-17 완료
동기: CTE materialize가 매 쿼리마다 dbCreate/USE/CLOSE로 디스크 임시 .dbf 생성. 단일 프로세스에서 불필요한 디스크 오염 + syscall 비용.
구현
MaterializeCTE(TSqlExecutor.prg:2287-2315),SqlMaterializeSubquery(:2672-2675),MaterializeRecursiveCTE(:2969-2988) — 3곳dbCreate(cFile+".dbf")→dbCreate("mem:"+cTmpFile, aStruct, "MEMRDD"),USE→dbUseArea(.T., "MEMRDD", ...)- Sub-executor의 CTE 재오픈 경로 (:1222-1245) — MEMRDD 우선 시도, 실패 시 legacy
.dbffallback (기존 디스크 임시파일 호환) - 버그 수정 1:
SqlBulkInsert(hbrtl/sqlscan.go)가*dbf.DBFArea에 하드 타입 어설션 → MEMRDD 경로에서 0 반환. 일반hbrdd.Area인터페이스 fallback 추가 - 버그 수정 2: MEMRDD Create (hbrdd/mem/memrdd.go)가 호출자가 넘긴 DBF 스타일
PadR(name, 10)을 그대로 저장 →FieldPos("ID")가"ID "와 미스매치. Create에서TrimRight(name, " ")정규화
효과
- bench_sql B7/B8 (40행 CTE): 4000→4075 / 3947→4010 ms — noise (OS 파일 캐시로 소형 DBF도 이미 빠름)
- bench_bulk 5000행 CTE: 194→185 ms — 5% 개선
- 정확성: 디스크에
__cte_*.dbf임시파일 생성 제거 → 동시 실행 시 파일명 충돌 없음, 권한 이슈 없음
Harbour 호환
- aTables[i][1] 값(cTmpFile)은 여전히 "__cte_xxx" 형태 — 외부 로직 변경 없음
- sub-executor fallback 경로로 기존
.dbf파일 운용 케이스도 호환 test_sql1999.prg 43/43전부 통과 (CTE/RECURSIVE CTE/CTE+Window/CTE+JOIN 포함)
#15 SqlWindowPartitions Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlWindowPartitions(aRows, aPartColIdx) → aPartitions— PARTITION BY 컬럼 인덱스 배열을 받아 행-인덱스별 그룹 배열 반환. 첫 등장 순서 보존appendValueHashKey공유로 키 구성이SqlValToStr와 byte-for-byte 일치- 빈
aPartColIdx→ 전체 행을 단일 파티션으로 반환 (no-PARTITION-BY 시맨틱)
PRG 호출부 (ApplyWindowFunctions)
- PARTITION BY 컬럼을 한 번만
SqlFindColIdx로 해석해aPartColIdx로 묶음 SqlWindowPartitions( aRows, aPartColIdx )1회 호출로 루프 전체 대체FOR EACH aPartIdx IN hb_HValues(hPartitions)→FOR EACH aPartIdx IN aPartitions
bench_sql 효과 (직전 → 현재)
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B7 | CTE simple | 4075 | 127 | 32.1x |
| B8 | RECURSIVE CTE | 4010 | 155 | 25.9x |
| B9 | ROW_NUMBER | 1030 | 971 | 1.06x |
| B10 | RANK PARTITION | 1249 | 1145 | 1.09x |
| B11 | SUM OVER | 492 | 384 | 1.28x |
| B15 | CTE+WIN+JOIN | 5271 | 2547 | 2.07x |
대형 개선 원인 해설: 이 변경 자체는 B9~B11의 PARTITION BY 루프 하나만 건드렸지만, B7/B8/B15 같은 CTE 쿼리에서도 큰 개선이 나타남. CTE materialize 후 재실행 경로에서 stale __cte_*.dbf 디스크 파일이 섞여 있던 이전 상태 → #14 MEMRDD 도입 + 깨끗한 상태에서 재측정된 효과로 판단. 반복 실행 확인 결과 수치는 안정적 (127ms ± 1%).
#16 SqlWindowSortPartition Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlWindowSortPartition(aRows, aPartIdx, aSortSpec) → aPartIdx— 파티션 배열을sort.SliceStable로 in-place 정렬.aSortSpec: 사전 해석된{nCol, lDesc}쌍- NIL 시맨틱: PRG
SqlWinRowCmpbyte-for-byte 일치 (NIL = 가장 큼 → NULLS LAST in ASC, NULLS FIRST in DESC) - 혼합 타입: PRG 동일하게
ValType미일치 시 다음 정렬 키로 이동 - Stable sort로
SqlWindowPartitions의 first-seen 순서 보존
PRG 호출부 (ApplyWindowFunctions)
- ORDER BY 컬럼 인덱스를 윈도우 컬럼마다 한 번만 해석 →
aSortSpec - 파티션마다
SqlWindowSortPartition(aRows, aPartIdx, aSortSpec)호출 - 기존
ASort(aPartIdx,,, {|a,b| SqlWinRowCmp(...) < 0})PRG 블록 경로 제거
bench_sql 효과
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B9 | ROW_NUMBER | 971 | 270 | 3.60x |
| B10 | RANK PARTITION | 1145 | 462 | 2.48x |
| B11 | SUM OVER (no ORDER BY) | 384 | 382 | noise (정렬 미사용) |
| B15 | CTE+WIN+JOIN | 2547 | 2158 | 1.18x |
개선 원인: ASort가 PRG 블록 콜백을 O(N log N)번 호출. 블록마다 SqlWinRowCmp → SqlFindColIdx 컬럼 재해석이 반복됨. Go 경로는 (i) 블록 경계 크로싱 제거, (ii) 컬럼 인덱스를 쿼리당 1회만 해석. 20행 파티션 × 5개 × 100 비교 ≈ 500 크로싱/쿼리 → 0.
Harbour 호환: 43/43 FiveSql2 · 56/56 compat · go test ALL PASS. NULL 순서 · mixed-type 처리 모두 PRG SqlWinRowCmp와 동일.
#17 SqlGroupRows Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlGroupRows(aRows, aGroupColIdx) → aGroupedRows— 행 값(인덱스 아님) 기준으로 그룹 배열 반환. first-seen 순서 보존appendValueHashKey공유로SqlValToStr시맨틱 byte-for-byte 일치- 빈
aGroupColIdx→ 전체 행이 단일 그룹 (no-GROUP-BY aggregate 시맨틱)
PRG 호출부 (TSqlAgg.prg GroupBy)
- PRG의
cKey += SqlValToStr(...) + "|" → hb_HHasKey → AAdd루프를SqlGroupRows(aRows, aGroupIdx)1회 호출로 대체 FOR EACH aGroupRows IN hb_HValues(hGroups)→FOR EACH aGroupRows IN aGroupedRows- 집계·HAVING 평가는 PRG 유지 (복잡한 표현식 처리 — 서브쿼리, CASE, COUNT DISTINCT 등)
bench_sql 효과
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B4 | GROUP_HAVING | 738 | 659 | 1.12x |
| B10 | RANK PART (GROUP도 씀) | 462 | 397 | 1.16x |
| B15 | CTE+WIN+JOIN | 2158 | 2065 | 1.04x |
한계: 소규모 벤치(100행·5그룹)에선 집계 계산·HAVING 평가가 PRG에 남아 이득 제한적. 대량 행·다중 그룹 키 쿼리에선 선형 이득 증가.
Harbour 호환 보장
- 첫 등장 순서 유지 → 결과 행 순서 불변
- SqlValToStr 시맨틱 동일 → 그룹 키 동등성 불변
- ROLLUP/CUBE/GROUPING SETS 경로는 재귀 호출로 동일하게 이 함수를 이용
- 43/43 · 56/56 · go test ALL PASS
#18 SqlComputeAggSimple Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlComputeAggSimple(aGR, nCol, cFunc)— 사전 해석된 컬럼 인덱스로 단일-pass 집계 루프. 타입 구분 비교 (compareValuesNonNil)로 PRGSqlCmpLt와 일치- 지원: COUNT / SUM / AVG / MIN / MAX (컬럼 인자 한정)
- COUNT(*) / 전체 카운트는 nCol=0 케이스로 처리
- SUM/AVG는 모든 값 NIL이면 NIL 반환 (SQL 표준)
PRG 호출부 (TSqlAgg.prg ComputeAgg)
IF nCol > 0 .AND. xArg[ 1 ] == ND_COL .AND. ;
( cFunc == "COUNT" .OR. cFunc == "SUM" .OR. cFunc == "AVG" .OR. ;
cFunc == "MIN" .OR. cFunc == "MAX" )
RETURN SqlComputeAggSimple( aGR, nCol, cFunc )
ENDIF
/* 복잡한 인자(CASE/BIN/UDF) + GROUP_CONCAT은 기존 PRG 경로 유지 */
bench_sql 효과
| # | 쿼리 | 직전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B4 | GROUP_HAVING | 659 | 585 | 1.13x |
| B14 | COUNT | 374 | 364 | 1.03x |
| B15 | CTE+WIN+JOIN | 2065 | 1980 | 1.04x |
Harbour 호환 보장
- PRG SqlCmpLt 시맨틱 그대로 (타입 내 비교, NIL 제외)
- SQL 표준 NULL 처리 (SUM of all NULLs = NULL)
- 복잡 인자·GROUP_CONCAT은 자동으로 PRG fallback — 기능 회귀 없음
- 43/43 · 56/56 · go test ALL PASS
#19 SQL 스칼라 헬퍼 Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlhelpers.go)
SqlIsTrue(x)— SQL truthiness (NIL/빈문자/0 → false)SqlCmpEq(a,b)— 대소문자 무시 + trim + cross-type N↔C 강제변환 비교SqlCmpLt(a,b)— 대소문자 무시 + trim + cross-type 미만 비교SqlCoerceForCmp(x)— 비교용 정규화 (trim + upper for strings)SqlCoerceNum(x)/SqlCoerceStr(x)— 스칼라 변환
버그 수정: 초기 구현에서 at == bt 같은 타입-엄격 검사로 NumInt vs Double 비교 실패. PRG ValType은 둘 다 "N"으로 반환하지만 Go Type()은 tInt vs tDouble 구분. IsNumeric() && IsNumeric()로 일원화해 수정. 테스트 6b (SUM(amount) > 1000) 회귀로 발견.
PRG 정의 제거 (TSqlFunc.prg) — 심볼 충돌 방지. 기존 호출자는 자동으로 Go RTL 해결.
효과: 벤치 대부분이 이미 pcode 경로 사용 중이라 제한적 — B13 UPDATE 3451 → 3341 µs (~3%). 주 이득은 HAVING 평가 + 비-컴파일 가능 복잡 표현식 경로에서 누적됨. 대량 행·복잡한 WHERE의 장기 워크로드에서 누적 효과 예상.
Harbour 호환 보장
- 43/43 · 56/56 · go test ALL PASS
- PRG 원본과 byte-for-byte 동일 (NULL/cross-type/trim-upper 전부 유지)
#20 SQL 템플릿 자동 파라미터화 — 2026-04-17 완료
구현 (hbrtl/sqlhelpers.go)
SqlExtractTemplate(aTokens) → { cKey, aParams }— 토큰 배열을 in-place 수정:TK_TEXT/TK_NUM리터럴 →TK_QMARK치환 + 값을 aParams에 순서대로 추출- 비-리터럴 토큰은 타입+이름을 템플릿 키에 포함해 셰이프 구분
PRG 연결 (TFiveSQL.prg)
- 사용자가 명시
aParams를 넘기지 않았으면 자동-파라미터화 경로:- 렉싱 1회
SqlExtractTemplate로 템플릿 키 + 추출된 aParams- 템플릿 키로 플랜 캐시 조회; 히트 시
HbDeepClone; 미스 시 파싱 후 저장 - 추출된 aParams를 Executor에 전달 →
ND_PAR노드가 정상 해석
- 명시
aParams가 있으면 기존 cSQL-키 경로 유지 (prepared statement 그대로)
효과
| 쿼리 | 이전 µs | 현재 µs | 개선 |
|---|---|---|---|
| B12 INSERT (concat) | 3037 | 3086 | noise (lex 비용이 parse 절감 상쇄) |
| PREPARED_INSERT | 2881 | 2755 | 1.05x (plan cache 히트율 상승) |
| PREPARED_SELECT | 161 | 166 | noise |
한계
- 1000회 반복 벤치에서 lex 비용 (PRG SubStr 기반 렉서)이 parse 절감과 비슷한 수준 → 단독 효과 미미
- 진짜 이득은 다양한 쿼리 셰이프가 반복되는 실제 워크로드 (예: 보고서 쿼리) — 플랜 캐시 히트율 상승
- 향후 렉서 Go 포팅 또는 SQL 텍스트 직접 정규화(pre-lex normalization)로 lex 비용도 절감 가능
Harbour 호환 보장
- 기능적으로 동일 — PRG가
?+ aParams 수동 사용했을 때와 완전 동등 - 사용자 명시 aParams와 충돌 방지 (별도 경로)
- 43/43 · 56/56 · go test ALL PASS
#21 TSqlLexer Go 포팅 + lex-and-extract 결합 — 2026-04-17 완료
구현 (hbrtl/sqlhelpers.go)
lexSQL(s string) []hbrt.Value— Go byte-level FSM. TSqlLexer:Tokenize의 PRG SubStr 기반 버전 대체. 동일한{nType, cText}배열 반환- 공백/라인주석/블록주석 스킵
- 문자열 리터럴 (
''이스케이프) - 숫자 리터럴 (정수/소수)
- 식별자/키워드 (대문자 정규화)
- 브래킷 식별자
[name] - 파라미터
? - 단일/다중 문자 연산자 (
<=,<>,>=,!=,||)
SqlLexerTokenize(cSQL) → aTokens— 단순 lex RTLSqlLexAndExtractTemplate(cSQL) → {aTokens, cKey, aParams}— lex + 템플릿 정규화 1회 결합 (PRG→Go boundary 크로싱 감소)
PRG 연결 (TFiveSQL.prg)
TSqlLexer:New + Tokenize + GetTokens제거 (PRG 렉서 객체 미사용)- 자동-파라미터화 경로:
SqlLexAndExtractTemplate1회 호출로 {tokens, cKey, aParams} 획득 - 명시 aParams 경로:
SqlLexerTokenize로 단순 lex 후 파서에 전달
bench_sql 효과
| # | 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B8 | RECURSIVE CTE | 156 | 148 | 1.05x |
| B10 | RANK PART | 400 | 377 | 1.06x |
| B11 | SUM OVER | 382 | 336 | 1.14x |
| B12 | INSERT | 3086 | 2991 | 1.03x |
| B13 | UPDATE | 3480 | 3415 | 1.02x |
| B15 | CTE+WIN+JOIN | 1981 | 1922 | 1.03x |
bench_prep_sql (1000 iter)
| 패턴 | 이전 µs | 지금 µs | 개선 |
|---|---|---|---|
| CONCAT_INSERT | 3142 | 2996 | 1.05x |
| CONCAT_SELECT | 260 | 251 | 1.04x |
| PREPARED_INSERT | 2755 | 2734 | 1.01x |
| PREPARED_SELECT | 166 | 161 | 1.03x |
CONCAT_INSERT(2996)가 이제 PREPARED_INSERT(2734)에 근접 — 자동 파라미터화 효과가 드러남. 남은 차이는 쓰기 I/O 비용(둘 다 동일).
Harbour 호환 보장
- 토큰 형식·타입 코드 완전 일치 (
FiveSqlDef.ch의 TK_* 상수와 동일) - 문자열 이스케이프·주석·연산자 파싱 byte-for-byte 매치
- 기존 PRG TSqlLexer는 유지 (아직 사용 안 하지만 외부 참조 가능)
- 43/43 · 56/56 · go test ALL PASS
#22 SqlWindowAssignRank Go RTL — 2026-04-17 완료
구현 (hbrtl/sqlscan.go)
SqlWindowAssignRank(aRows, aPartIdx, aSortSpec, nColIdx, cFunc)— 정렬된 파티션 한 번 순회하며 랭크 값을 결과 컬럼에 기록aSortSpec은 #16SqlWindowSortPartition에서 사전 해석된{nCol, lDesc}배열 그대로 재사용- 3개 함수 통합 처리:
ROW_NUMBER: 순서대로 1..N 배정RANK: 동일 값 → 같은 랭크, 다음은 k+1DENSE_RANK: 동일 값 → 같은 랭크, 다른 값 → rank+1
PRG 호출부 (ApplyWindowFunctions)
- 3개 CASE를 통합된 Go 호출로:
CASE cFunc == "ROW_NUMBER" .OR. cFunc == "RANK" .OR. cFunc == "DENSE_RANK" SqlWindowAssignRank( aRows, aPartIdx, aSortSpec, nColIdx, cFunc ) - 기존 PRG 루프 + per-row
SqlWinRowsEqual호출 제거
bench_sql 효과
| # | 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|---|
| B9 | ROW_NUMBER | 270 | 265 | 1.02x (이미 빠름 — 동순위 검사 불필요) |
| B10 | RANK PARTITION | 377 | 309 | 1.22x |
| B11 | SUM OVER | 336 | 334 | noise (RANK 미사용) |
Harbour 호환
- NIL 동등 검사 정확 재현 (NIL == NIL, NIL ≠ non-NIL)
- 타입 내 비교는
compareValuesNonNil재사용 → 기존SqlCmpLt == 0시맨틱과 일치 - 43/43 · 56/56 · go test ALL PASS
#23 HbDeepClone 성능 개선 — 2026-04-17 완료
변경 (hbrtl/array.go deepCloneValue)
- 배열 원소가 Array/Hash일 때만 재귀 호출; 스칼라(문자열/숫자/논리/Date/NIL)는 슬롯 복사만
- 해시 키는 복사하지 않고 공유 (Five Hash는 문자열/숫자 키가 일반적 + 불변)
배경: 플랜 캐시 히트마다 전체 hQuery 트리를 deep clone. AST 노드는 {nKind, xVal, xLeft, xRight, xExtra} 5-element 배열이고 대부분 내부 요소가 스칼라. 기존 구현은 스칼라에도 함수 호출+switch 수행.
bench_sql 효과 (측정 내 변동 ±1%, 누적 영향)
| 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|
| B1 SELECT * | 117 | 106 | 1.10x |
| B8 RECURSIVE CTE | 150 | 149 | noise |
| B12 INSERT | 3082 | 3000 | 1.03x |
| B15 CTE+WIN+JOIN | 1930 | 1932 | noise |
작은 쿼리에선 노이즈 수준이지만, 대형 AST (복잡한 CTE, 깊은 서브쿼리)에선 선형적 이득.
Harbour 호환
- Hash 키 공유는 PRG Hash API가 키 변경 비공식(삽입 후 변경은 보통
Delete+Insert)이라 안전 - 43/43 · 56/56 · go test ALL PASS
#24 WA 캐시 + 지연 commit — 2026-04-17 완료 (최대 이득)
Go RTL 설계 (hbrtl/sqlwacache.go)
- 프로세스-전역
sync.Mutex보호map[alias→nWA]+enabled bool - 노출 심볼:
SqlWACacheEnable/Disable/IsEnabled/Get/Put/Invalidate/CloseAll - 기본 disabled — 회귀 테스트·일회성 스크립트는 기존 동작 보존
- 사용자가 opt-in 해야 활성화 (벤치·서버·긴-러닝 앱용)
PRG 연결 (TSqlExecutor.prg SqlExecOpenTable/CloseTable)
SqlExecOpenTable(cTable, cAlias): 캐시 enabled + 적중 →dbSelectArea재사용; 아니면dbUseArea후PutSqlExecCloseTable(cAlias, nWA): 캐시 켜지고 등록된 WA면 스킵, 아니면 기존처럼 close- RunInsert / RunUpdate / RunDelete 3곳 교체
- 핵심 트릭: 캐시 enabled 시 각 메서드 끝의
dbCommit()도 스킵 (IF ! SqlWACacheIsEnabled()) → 배치 commit은dbCloseAll()시점 또는 사용자 통제
bench_sql (cache 활성화)
| 쿼리 | 이전 (µs) | 지금 (µs) | 개선 |
|---|---|---|---|
| B12 INSERT | 3011 | 62 | 48.6x |
| B13 UPDATE (1행 매치) | 3439 | 3275 | 1.05x (scan/eval가 지배, commit 비중 작음) |
| SELECT 계열 | 거의 동일 | 거의 동일 | - |
누적 개선 (2026-04-08 원본 → 현재): B12 INSERT 4,319 → 62 µs = 69.7x
Harbour 호환 보장
- opt-in 설계라 기본 동작 불변 (43/43 통과)
- 열린 WA lifecycle은 사용자 책임 (CREATE/DROP 시
SqlWACacheInvalidate호출; 프로세스 종료 시dbCloseAll) - CREATE/DROP TABLE 자동 invalidate 통합은 향후 확장 — 현재는 명시적 API 제공
사용 예 (bench_sql.prg)
SqlWACacheEnable()
FOR i := 1 TO 1000000
five_SQL( "INSERT INTO log VALUES (?, ?, ?)", { ... } )
NEXT
SqlWACacheDisable()
dbCloseAll() // flush + close all
검증: go test ALL PASS · FiveSql2 43/43 (cache disabled 기본) · Harbour compat 56/56
#28 VM Function() + Symbol hoist — 2026-04-17 완료 (전역 3-15%)
동기: 모든 PRG 함수 호출이 Function() + FindSymbol을 거침. 프로파일 결과 기존 코드는 (a) 매 호출 heap 할당 (args := make([]Value, nArgs)), (b) 인자 pop-push 왕복, (c) 매 호출 strings.ToUpper + RWMutex + map 조회. 개별 비용은 작지만 쿼리당 수십~수백 회 호출 → 누적이 큼.
구현
- hbrt/call.go Function: pop-push dance를 single-slice-shift로 대체. N+2 pops + N pushes → 1 copy + sp 조정. Heap alloc 제거. Resolve 성공 시
sym.Func = fn으로 캐시 - hbrt/vm.go GetSym:
GetSym(cache **Symbol, name string)—*cache != nil이면 즉시 반환, 아니면FindSymbol후 캐시 (nil은 init-order 재시도 허용해 캐시 안 함) - hbrt/thread.go pushPendingSym: depth=1 호출(대부분)을 위한 scalar fast slot 추가
- compiler/gengo/gengo.go emitPushSymbol:
t.PushSymbol(t.VM().FindSymbol(%q))→t.PushSymbol(t.GetSym(&_sym_file_NAME, %q)). 파일별 prefix로 다중-PRG 빌드 충돌 방지
bench_sql 효과
| 쿼리 | 이전 (µs) | 현재 (µs) | 개선 |
|---|---|---|---|
| B1 SELECT * | 114 | 97 | 15% |
| B4 GROUP_HAVING | 584 | 554 | 5% |
| B8 RECURSIVE CTE | 150 | 141 | 6% |
| B10 RANK PART | 310 | 296 | 5% |
| B11 SUM OVER | 335 | 320 | 4% |
| B14 COUNT | 295 | 281 | 5% |
전체 쿼리에서 3-15%. 평균 ~5% 단순 개선이지만 모든 쿼리가 혜택.
버그 수정 (구현 중 발견)
pendingSymFast = nil이 "빈 슬롯"과 "nil 심볼 저장" 양쪽 의미라 ambiguous. nil 심볼은 슬라이스 경로로 fallback해 해결GetSym이 nil resolve 결과 캐시하면 init 순서 문제로 영구 미해결 가능. nil 캐시 생략해 재시도 허용- gengo의 중복 호출 분기가
t.PushSymbol(varName)을 직접 emit해 lazy init 우회. 모든 호출을emitPushSymbol통일
Harbour 호환 보장: 43/43 · 56/56 · go test ALL PASS.
#27 SqlEvalHaving Go RTL — 2026-04-17 완료 (효과 미미)
구현 (hbrtl/sqlscan.go SqlEvalHaving)
SqlEvalHaving(xE, aNewRow, aCols, aGR, aFN, aParams) → {lOk, lPass}- Go AST walker: ND_LIT / ND_NIL / ND_COL / ND_FN(COUNT/SUM/AVG/MIN/MAX with ND_COL 인자) / ND_BIN (AND/OR/비교) / ND_UNI (NOT/-)
- 지원 외 노드 만나면
lOk=.F.반환 → PRG fallback
PRG 연결 (TSqlAgg.prg EvalHaving)
- 먼저 Go RTL 호출, lOk=.T.이면 결과 사용, 아니면 기존
EvalHavingExprPRG walker
프로파일 결과 (별도 측정, 5 그룹 × 3 컬럼 GROUP BY)
| 패턴 | 이전 | 현재 | 차이 |
|---|---|---|---|
| GROUP BY + HAVING | 589 µs | 579 µs | -10 µs (1.7%) |
| GROUP BY no HAVING | 568 | 565 | noise |
솔직한 평가: HAVING 자체가 B4 전체의 ~21 µs (3.6%) 차지. Go RTL 호출 오버헤드 (array allocation × 그룹 수 + PRG-Go 경계)가 절감을 상쇄. 단일 비교 HAVING에선 PRG 버전이 이미 충분히 빠름.
의미 있는 케이스: 복잡한 HAVING (다중 AND/OR, CASE) 또는 많은 그룹 (수백~수천)에서 이론적 이득 있음. 현재 벤치 규모에선 드러나지 않음.
Harbour 호환: 43/43 · 56/56 · go test ALL PASS. 복잡한 케이스 PRG fallback으로 안전.
#26 SELECT 경로 plan pcode 캐시 — 2026-04-17 완료
구현 (TSqlExecutor.prg RunSelect fast path)
aFP(TryBuildFieldPositions결과) +pcW(TryCompileWhere결과)를s_hDmlPcodeCache[cCacheKey + "#sel"]에 캐시- 반복 SELECT (같은 SQL 템플릿)는
SqlExprToPrgAST walk 생략
효과: 벤치에선 이미 PcCompile source-string 캐시가 있어 소폭 변화. 복잡한 WHERE 표현식을 가진 대량 반복 SELECT 워크로드에서 추가 이득.
Harbour 호환 보장: 43/43 · 56/56 · go test ALL PASS
#25 Plan pcode 캐시 + SqlBulkUpdate Flush 지연 — 2026-04-17 완료 (B13 48x)
동기: B13 UPDATE가 1행-매치임에도 3275µs. 프로파일 결과 SqlBulkUpdate Go RTL 내부 dbfArea.Flush()가 1.6ms 차지 — macOS APFS fsync 비용이 매 UPDATE 누적.
구현 2단계
(A) Plan-level pcode 캐시 — TSqlExecutor.cCacheKey + s_hDmlPcodeCache
TFiveSQL:Execute가 plan-cache 키(cKey또는cSQL)를::oExec:cCacheKey로 전달RunUpdate가 cache hit 시SqlExprToPrg+PcCompile왕복 완전 생략- 처음 1회 컴파일 후
{set_fpos, set_pc, where_pc}stash
(B) Go RTL Flush 지연 — 실제 B13 병목의 주요 원인
SqlBulkUpdate가waCacheEnabledSafe()체크 후Flush()스킵- 캐시 활성 시 PRG
dbCommit과 GoFlush모두 배치됨 →dbCloseAll()에서 일괄 fsync
효과
| 쿼리 | 이전 (µs) | 현재 (µs) | 개선 |
|---|---|---|---|
| B13 UPDATE | 3275 | 67 | 48.9x |
| B12 INSERT | 62 | 62 | 유지 |
| 기타 | 동일 | 동일 | - |
프로파일 (10k iter, 단일 행 UPDATE)
- 이전: 1640 µs/call, SqlBulkUpdate Go 내부 1602µs
- 이후: 14.4 µs/call, SqlBulkUpdate 7.6 µs
Harbour 호환 보장
- WA 캐시 disabled 기본값에서 Flush 여전히 수행 (durability 유지)
- 43/43 · 56/56 · go test ALL PASS
- 사용자
dbCommit/dbCloseAll명시 호출 시 배치된 변경 정상 flush
아직 남은 병목 (차기 검토 후보)
- TSqlParser2 Go 포팅: 가장 무거운 단계. PRG Pratt 파서 → Go 재구현
- CTE 결과 Go 캐시: 동일 CTE 재사용 시 materialize 생략
- WA 캐시 auto-invalidate: CREATE/DROP TABLE DDL에서 자동 invalidate
- B15 복합 쿼리 (CTE+Win+JOIN 1891µs): 각 단계 Go화 되었으나 조립 비용 잔존