perf: RTL Go-native migration — 27 optimizations, DML up to 70-90x
Systematic pass through PRG hot paths, promoting them to Go RTL while
preserving Harbour/FiveSql2 semantics. Full log in
docs/RTL-Go-Native-Migration.md.
Bench (bench_sql) vs 2026-04-08 baseline
- B1 SELECT * 2,192 → 114 µs (19x)
- B6 INNER JOIN 9,291 → 233 µs (40x)
- B7 CTE simple 8,037 → 129 µs (62x)
- B9 ROW_NUMBER 3,705 → 265 µs (14x)
- B10 RANK PARTITION 4,748 → 309 µs (15x)
- B12 INSERT (WA cache) 4,319 → 63 µs (69x)
- B13 UPDATE (WA cache) 6,144 → 68 µs (90x)
- B15 CTE+WIN+JOIN 18,395 → 1,873 µs (10x)
Infrastructure
- HbHash O(1) Index preserving insertion order (Harbour KEEPORDER)
- HbDeepClone Go RTL (scalar-sharing, immutable hash keys)
- MEMRDD auto-imported via gengo; all Five programs get mem:name driver
- SQL plan + pcode caches (s_hPlanCache, s_hDmlPcodeCache)
- Opt-in SqlWACacheEnable — dbUseArea/Close/Commit batched for DML
SQL engine
- FiveSql2 lexer ported to Go (byte FSM) with combined automatic
template parameterization (literals → ?, concat queries share plan)
- Go RTL: SqlDistinct, SqlGroupRows, SqlWindowPartitions,
SqlWindowSortPartition, SqlWindowAssignRank, SqlComputeAggSimple,
SqlBulkInsert, SqlBulkUpdate, SqlExprHasAgg, SqlEvalHaving
- CTE / subquery / driving-table materialize paths use MEMRDD
- SqlCoerce/SqlCmp/SqlIsTrue helpers moved from PRG to Go
- SqlBulkUpdate defers Flush when WA cache active (APFS fsync was
dominant B13 cost — 1.6ms/call → gone)
Correctness fixes uncovered during migration
- ASort default path now sorts dates/logicals/timestamps (was no-op)
- ORDER BY default NULL placement matches PRG SqlRowCompare across
Go fast path; explicit NULLS FIRST/LAST honored by both paths
- SqlBulkUpdate respects EXCLUSIVE vs SHARED mode record locks
- SqlCmp/SqlCmpEq normalize NumInt vs Double (caught by test 6b)
Verification
- go test ./... ALL PASS
- FiveSql2 test_sql1999 43/43
- tests/compat_harbour 56/56 (+5 new: ASort dates/logicals,
AScan int cross-type)
- Regression test test_null_order.prg for ORDER BY NULL ordering
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
271
hbrtl/array.go
271
hbrtl/array.go
@@ -98,6 +98,69 @@ func AClone(t *hbrt.Thread) {
|
||||
t.RetValue()
|
||||
}
|
||||
|
||||
// HbDeepClone recursively clones a value. Arrays and hashes are cloned
|
||||
// element-by-element; scalars (string, number, logical, date, NIL) are
|
||||
// returned unchanged — Five strings/numbers are immutable so sharing
|
||||
// pointers is safe. Used by FiveSql2's plan cache to hand callers a
|
||||
// pristine copy of the parsed query tree on every cache hit, since
|
||||
// Run() mutates some nodes (SqlFoldConst in particular).
|
||||
//
|
||||
// Harbour: hb_DeepCopy(xVal) → xNewVal
|
||||
func HbDeepClone(t *hbrt.Thread) {
|
||||
t.Frame(1, 0)
|
||||
defer t.EndProc()
|
||||
t.PushValue(deepCloneValue(t.Local(1)))
|
||||
t.RetValue()
|
||||
}
|
||||
|
||||
// deepCloneValue walks Array and Hash structures recursively; other
|
||||
// Value kinds are returned as-is (scalars are immutable in Five so
|
||||
// sharing is safe).
|
||||
//
|
||||
// Hot-path optimizations:
|
||||
// - Array items that are themselves scalars skip the function call
|
||||
// (just slot-copied). Recursion only fires for nested Array/Hash.
|
||||
// - Hash keys are shared (never cloned). PRG hashes carry string /
|
||||
// numeric keys in every observed call site; mutating a key after
|
||||
// insertion is forbidden by the Hash API, so sharing is safe and
|
||||
// saves the recursion plus per-key allocation.
|
||||
func deepCloneValue(v hbrt.Value) hbrt.Value {
|
||||
if v.IsArray() {
|
||||
src := v.AsArray()
|
||||
if src == nil {
|
||||
return v
|
||||
}
|
||||
n := len(src.Items)
|
||||
items := make([]hbrt.Value, n)
|
||||
for i := 0; i < n; i++ {
|
||||
item := src.Items[i]
|
||||
if item.IsArray() || item.IsHash() {
|
||||
items[i] = deepCloneValue(item)
|
||||
} else {
|
||||
items[i] = item
|
||||
}
|
||||
}
|
||||
return hbrt.MakeArrayFrom(items)
|
||||
}
|
||||
if v.IsHash() {
|
||||
src := v.AsHash()
|
||||
if src == nil {
|
||||
return v
|
||||
}
|
||||
nh := hbrt.MakeHash()
|
||||
dst := nh.AsHash()
|
||||
for i, k := range src.Keys {
|
||||
val := src.Values[i]
|
||||
if val.IsArray() || val.IsHash() {
|
||||
val = deepCloneValue(val)
|
||||
}
|
||||
dst.Append(k, val)
|
||||
}
|
||||
return nh
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ACopy copies elements from one array to another.
|
||||
// Harbour: ACopy(aSource, aDest [, nStart [, nCount [, nTargetPos]]]) → aDest
|
||||
func ACopy(t *hbrt.Thread) {
|
||||
@@ -133,6 +196,12 @@ func AFill(t *hbrt.Thread) {
|
||||
|
||||
// ASort sorts an array using an optional comparison block.
|
||||
// Harbour: ASort(aArray [, nStart [, nCount [, bBlock]]]) → aArray
|
||||
//
|
||||
// Block path: invokes bBlock per compare (side-effect safe).
|
||||
// Default path (no block): one pre-scan picks a specialized comparator
|
||||
// for homogeneous arrays (string / numeric / date / timestamp /
|
||||
// logical); mixed or unknown element types fall back to a generic
|
||||
// less-than that matches Harbour's default `<` semantics across types.
|
||||
func ASort(t *hbrt.Thread) {
|
||||
nParams := t.ParamCount()
|
||||
t.Frame(nParams, 0)
|
||||
@@ -140,9 +209,13 @@ func ASort(t *hbrt.Thread) {
|
||||
|
||||
arrVal := t.Local(1)
|
||||
arr := arrVal.AsArray()
|
||||
if arr == nil || len(arr.Items) < 2 {
|
||||
t.PushValue(arrVal)
|
||||
t.RetValue()
|
||||
return
|
||||
}
|
||||
|
||||
if nParams >= 4 && t.Local(4).IsBlock() {
|
||||
// Sort with code block comparator
|
||||
blk := t.Local(4).AsBlock()
|
||||
sort.SliceStable(arr.Items, func(i, j int) bool {
|
||||
t.PushValue(arr.Items[i])
|
||||
@@ -151,17 +224,47 @@ func ASort(t *hbrt.Thread) {
|
||||
blk.Fn(t)
|
||||
return t.GetRetValue().AsBool()
|
||||
})
|
||||
} else {
|
||||
// Default sort: by value comparison
|
||||
sort.SliceStable(arr.Items, func(i, j int) bool {
|
||||
a, b := arr.Items[i], arr.Items[j]
|
||||
if a.IsString() && b.IsString() {
|
||||
return a.AsString() < b.AsString()
|
||||
t.PushValue(arrVal)
|
||||
t.RetValue()
|
||||
return
|
||||
}
|
||||
|
||||
// Default sort — pick a type-specialized comparator when every
|
||||
// element shares a shape. Falls back to a generic less-than for
|
||||
// mixed or uncategorized types.
|
||||
items := arr.Items
|
||||
switch detectArrayKind(items) {
|
||||
case arrKindString:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].AsString() < items[j].AsString()
|
||||
})
|
||||
case arrKindInt:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].AsNumInt() < items[j].AsNumInt()
|
||||
})
|
||||
case arrKindNumeric:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].AsNumDouble() < items[j].AsNumDouble()
|
||||
})
|
||||
case arrKindDate:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].AsJulian() < items[j].AsJulian()
|
||||
})
|
||||
case arrKindTimestamp:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ja, jb := items[i].AsJulian(), items[j].AsJulian()
|
||||
if ja != jb {
|
||||
return ja < jb
|
||||
}
|
||||
if a.IsNumeric() && b.IsNumeric() {
|
||||
return a.AsNumDouble() < b.AsNumDouble()
|
||||
}
|
||||
return false
|
||||
return items[i].AsTimeMs() < items[j].AsTimeMs()
|
||||
})
|
||||
case arrKindLogical:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return !items[i].AsBool() && items[j].AsBool()
|
||||
})
|
||||
default:
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return valueLess(items[i], items[j])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +272,98 @@ func ASort(t *hbrt.Thread) {
|
||||
t.RetValue()
|
||||
}
|
||||
|
||||
type arrKind int
|
||||
|
||||
const (
|
||||
arrKindMixed arrKind = iota
|
||||
arrKindString
|
||||
arrKindInt
|
||||
arrKindNumeric
|
||||
arrKindDate
|
||||
arrKindTimestamp
|
||||
arrKindLogical
|
||||
)
|
||||
|
||||
// detectArrayKind returns a specialized kind when every element matches
|
||||
// one well-known type; otherwise arrKindMixed. Integer-only arrays
|
||||
// prefer arrKindInt to skip the int→double conversion in the hot path.
|
||||
// A single non-int numeric promotes the whole array to arrKindNumeric.
|
||||
func detectArrayKind(items []hbrt.Value) arrKind {
|
||||
if len(items) == 0 {
|
||||
return arrKindMixed
|
||||
}
|
||||
allInt := true
|
||||
for _, v := range items {
|
||||
if !v.IsNumInt() {
|
||||
allInt = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allInt {
|
||||
return arrKindInt
|
||||
}
|
||||
allNum := true
|
||||
for _, v := range items {
|
||||
if !v.IsNumeric() {
|
||||
allNum = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allNum {
|
||||
return arrKindNumeric
|
||||
}
|
||||
check := func(pred func(hbrt.Value) bool) bool {
|
||||
for _, v := range items {
|
||||
if !pred(v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if check(func(v hbrt.Value) bool { return v.IsString() }) {
|
||||
return arrKindString
|
||||
}
|
||||
if check(func(v hbrt.Value) bool { return v.IsDate() }) {
|
||||
return arrKindDate
|
||||
}
|
||||
if check(func(v hbrt.Value) bool { return v.IsTimestamp() }) {
|
||||
return arrKindTimestamp
|
||||
}
|
||||
if check(func(v hbrt.Value) bool { return v.IsLogical() }) {
|
||||
return arrKindLogical
|
||||
}
|
||||
return arrKindMixed
|
||||
}
|
||||
|
||||
// valueLess implements Harbour's default `<` across types. NILs sort
|
||||
// first (smallest) so they group together — matches the historical
|
||||
// Five compareValues behavior that ASort inherited.
|
||||
func valueLess(a, b hbrt.Value) bool {
|
||||
if a.IsNil() || b.IsNil() {
|
||||
return a.IsNil() && !b.IsNil()
|
||||
}
|
||||
if a.IsNumeric() && b.IsNumeric() {
|
||||
return a.AsNumDouble() < b.AsNumDouble()
|
||||
}
|
||||
if a.IsString() && b.IsString() {
|
||||
return a.AsString() < b.AsString()
|
||||
}
|
||||
if a.IsDate() && b.IsDate() {
|
||||
return a.AsJulian() < b.AsJulian()
|
||||
}
|
||||
if a.IsTimestamp() && b.IsTimestamp() {
|
||||
ja, jb := a.AsJulian(), b.AsJulian()
|
||||
if ja != jb {
|
||||
return ja < jb
|
||||
}
|
||||
return a.AsTimeMs() < b.AsTimeMs()
|
||||
}
|
||||
if a.IsLogical() && b.IsLogical() {
|
||||
return !a.AsBool() && b.AsBool()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AEval evaluates a block for each element in array.
|
||||
// Harbour: AEval(aArray, bBlock [, nStart [, nCount]]) → aArray
|
||||
func AEval(t *hbrt.Thread) {
|
||||
@@ -201,6 +396,12 @@ func AEval(t *hbrt.Thread) {
|
||||
|
||||
// AScan searches for a value in array, returns position (0 if not found).
|
||||
// Harbour: AScan(aArray, xValue|bBlock [, nStart [, nCount]]) → nPos
|
||||
//
|
||||
// Block path: per-element block invoke (side-effect safe).
|
||||
// Value path: specialized fast-paths for string / int / double search
|
||||
// values — the loop stays inside Go without running through the
|
||||
// generic valuesEqual type-dispatch each iteration. Mixed or rare
|
||||
// types (date, timestamp, logical, nil) fall back to valuesEqual.
|
||||
func AScan(t *hbrt.Thread) {
|
||||
nParams := t.ParamCount()
|
||||
t.Frame(nParams, 0)
|
||||
@@ -208,11 +409,16 @@ func AScan(t *hbrt.Thread) {
|
||||
|
||||
arrVal := t.Local(1)
|
||||
arr := arrVal.AsArray()
|
||||
if arr == nil {
|
||||
t.RetInt(0)
|
||||
return
|
||||
}
|
||||
items := arr.Items
|
||||
search := t.Local(2)
|
||||
|
||||
if search.IsBlock() {
|
||||
blk := search.AsBlock()
|
||||
for i, item := range arr.Items {
|
||||
for i, item := range items {
|
||||
t.PushValue(item)
|
||||
t.PendingParams2(1)
|
||||
blk.Fn(t)
|
||||
@@ -221,8 +427,45 @@ func AScan(t *hbrt.Thread) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i, item := range arr.Items {
|
||||
t.RetInt(0)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case search.IsString():
|
||||
s := search.AsString()
|
||||
for i, item := range items {
|
||||
if item.IsString() && item.AsString() == s {
|
||||
t.RetInt(int64(i + 1))
|
||||
return
|
||||
}
|
||||
}
|
||||
case search.IsNumInt():
|
||||
n := search.AsNumInt()
|
||||
for i, item := range items {
|
||||
if !item.IsNumeric() {
|
||||
continue
|
||||
}
|
||||
if item.IsNumInt() {
|
||||
if item.AsNumInt() == n {
|
||||
t.RetInt(int64(i + 1))
|
||||
return
|
||||
}
|
||||
} else if item.AsNumDouble() == float64(n) {
|
||||
t.RetInt(int64(i + 1))
|
||||
return
|
||||
}
|
||||
}
|
||||
case search.IsNumeric():
|
||||
f := search.AsNumDouble()
|
||||
for i, item := range items {
|
||||
if item.IsNumeric() && item.AsNumDouble() == f {
|
||||
t.RetInt(int64(i + 1))
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
for i, item := range items {
|
||||
if valuesEqual(item, search) {
|
||||
t.RetInt(int64(i + 1))
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user