Skip to content

Commit 3ce97d2

Browse files
authored
fix(plan): bound right dedup memory (#25936)
RIGHT DEDUP builds the target map and then inserts every incoming key while probing. That probe-side growth cannot transition into the current build-side spill engine, so large insert-select statements can retain tens of GiB and OOM a CN. This PR: - estimates RIGHT DEDUP's retained hash-map allocation using the runtime table's capacity and load-factor rules; - accounts for target plus incoming keys and sums chained primary/unique-key DEDUP maps; - falls back the entire chain to normal DEDUP when the estimate reaches the configured join spill threshold or default CN-relative stage budget; - treats missing, non-finite, and sentinel default statistics conservatively; - preserves the row-count semantics of small `join_spill_mem` values; - keeps FLOAT normal DEDUP unshuffled because FLOAT hash shuffle is not supported yet, avoiding an invalid plan while retaining single-CN spill. The default combined budget is approximately one eighth of CN memory after the file-cache reservation. Normal DEDUP hash-shuffles supported keys; FLOAT keys stay unshuffled and spill locally. Validation: - `.agents/skills/mo-dev/scripts/mo-cgo-test -count=1 -timeout=180s ./pkg/sql/plan` - `go build -mod=readonly ./pkg/sql/plan` - `go vet -mod=readonly ./pkg/sql/plan` - `go build -mod=readonly ./pkg/container/hashtable` - `go vet -mod=readonly ./pkg/container/hashtable` - `go test -count=1 -timeout=120s ./pkg/container/hashtable` - `../mo-tester/run.sh -n -g -p test/distributed/cases/operator/in_range_operator.sql` (368/368 passed) Design decision: this selects the already-spillable normal DEDUP implementation for memory-unsafe plans instead of attempting to switch RIGHT DEDUP after it has streamed rows downstream. RIGHT DEDUP remains a small-input optimization; absent/default statistics take the safe fallback. FLOAT shuffle support is intentionally deferred to a separate PR. Approved by: @XuPeng-SH
1 parent 0505e2a commit 3ce97d2

10 files changed

Lines changed: 478 additions & 4 deletions

File tree

pkg/container/hashtable/common.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ func maxElemCnt(cellCnt, cellSize uint64) uint64 {
3737
return cellCnt * 4 / 5
3838
}
3939

40+
// EstimateInt64HashMapSize returns the cell allocation required by an
41+
// Int64HashMap for the given number of distinct keys. Keep this calculation
42+
// in the hashtable package so planner memory guards follow the same growth and
43+
// load-factor rules as the runtime implementation.
44+
func EstimateInt64HashMapSize(cardinality uint64) uint64 {
45+
return estimateHashMapSize(cardinality, intCellSize)
46+
}
47+
48+
// EstimateStringHashMapSize returns the cell allocation required by a
49+
// StringHashMap for the given number of distinct keys.
50+
func EstimateStringHashMapSize(cardinality uint64) uint64 {
51+
return estimateHashMapSize(cardinality, strCellSize)
52+
}
53+
54+
func estimateHashMapSize(cardinality, cellSize uint64) uint64 {
55+
const maxUint64 = ^uint64(0)
56+
57+
cellCnt := uint64(kInitialCellCnt)
58+
for {
59+
if cellCnt > maxUint64/cellSize {
60+
return maxUint64
61+
}
62+
if maxElemCnt(cellCnt, cellSize) >= cardinality {
63+
return cellCnt * cellSize
64+
}
65+
if cellCnt > maxUint64/2 {
66+
return maxUint64
67+
}
68+
cellCnt <<= 1
69+
}
70+
}
71+
4072
func toUnsafePointer[T any](p *T) unsafe.Pointer {
4173
return unsafe.Pointer(p)
4274
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2026 Matrix Origin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package hashtable
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
func TestEstimateHashMapSizeFollowsRuntimeGrowth(t *testing.T) {
24+
require.Equal(t, uint64(16*1024), EstimateInt64HashMapSize(0))
25+
require.Equal(t, uint64(16*1024), EstimateInt64HashMapSize(512))
26+
require.Equal(t, uint64(32*1024), EstimateInt64HashMapSize(513))
27+
28+
require.Equal(t, uint64(32*1024), EstimateStringHashMapSize(0))
29+
require.Equal(t, uint64(32*1024), EstimateStringHashMapSize(512))
30+
require.Equal(t, uint64(64*1024), EstimateStringHashMapSize(513))
31+
32+
require.Equal(t, ^uint64(0), EstimateInt64HashMapSize(^uint64(0)))
33+
require.Equal(t, ^uint64(0), EstimateStringHashMapSize(^uint64(0)))
34+
}

pkg/sql/colexec/dedupjoin/join.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ func (ctr *container) finalize(ap *DedupJoin, proc *process.Process) error {
363363
if ctr.matched == nil {
364364
return nil
365365
}
366-
if ap.NumCPU > 1 {
366+
if ap.needsFinalizeMerge() {
367367
if !ap.IsMerger {
368368
msg := &WorkerJoinMsg{matched: ctr.matched}
369369
if len(ap.OldColCapturePlaceholderIdxList) > 0 {

pkg/sql/colexec/dedupjoin/join_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,97 @@ func TestDedupResetClearsBucketState(t *testing.T) {
151151
proc.Free()
152152
}
153153

154+
func TestDedupShuffleWorkersFinalizeTheirOwnPartitions(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
isShuffle bool
158+
wantOutput bool
159+
wantMessage bool
160+
}{
161+
{
162+
name: "broadcast worker defers to merger",
163+
wantMessage: true,
164+
},
165+
{
166+
name: "shuffle worker emits local partition",
167+
isShuffle: true,
168+
wantOutput: true,
169+
},
170+
}
171+
172+
for _, test := range tests {
173+
t.Run(test.name, func(t *testing.T) {
174+
proc := testutil.NewProcessWithMPool(t, "", mpool.MustNewZero())
175+
baseline := proc.Mp().CurrNB()
176+
typ := types.T_int32.ToType()
177+
bat := batch.NewOffHeapWithSize(1)
178+
bat.Vecs[0] = vector.NewOffHeapVecWithType(typ)
179+
require.NoError(t, vector.AppendFixed(bat.Vecs[0], int32(42), false, proc.Mp()))
180+
bat.SetRowCount(1)
181+
182+
jm := message.NewJoinMap(message.GroupSels{}, nil, nil, nil, []*batch.Batch{bat}, proc.Mp())
183+
jm.SetRowCount(1)
184+
jm.IncRef(1)
185+
matched := &bitmap.Bitmap{}
186+
matched.InitWithSize(1)
187+
ch := make(chan *WorkerJoinMsg, 1)
188+
arg := &DedupJoin{
189+
RightTypes: []types.Type{typ},
190+
Result: []colexec.ResultPos{{Rel: 1, Pos: 0}},
191+
OnDuplicateAction: plan.Node_FAIL,
192+
NumCPU: 2,
193+
IsMerger: false,
194+
IsShuffle: test.isShuffle,
195+
Channel: ch,
196+
}
197+
arg.ctr.mp = jm
198+
arg.ctr.batches = jm.GetBatches()
199+
arg.ctr.batchRowCount = jm.GetRowCount()
200+
arg.ctr.matched = matched
201+
202+
require.NoError(t, arg.ctr.finalize(arg, proc))
203+
if test.wantOutput {
204+
require.Len(t, arg.ctr.buf, 1)
205+
require.Equal(t, []int32{42}, vector.MustFixedColNoTypeCheck[int32](arg.ctr.buf[0].Vecs[0]))
206+
} else {
207+
require.Nil(t, arg.ctr.buf)
208+
}
209+
require.Equal(t, test.wantMessage, len(ch) == 1)
210+
211+
arg.Free(proc, false, nil)
212+
require.Equal(t, baseline, proc.Mp().CurrNB())
213+
proc.Free()
214+
})
215+
}
216+
}
217+
218+
func TestDedupResetNotifiesOnlySharedBuildMerger(t *testing.T) {
219+
for _, test := range []struct {
220+
name string
221+
isShuffle bool
222+
wantMessage bool
223+
}{
224+
{name: "broadcast worker notifies merger", wantMessage: true},
225+
{name: "shuffle worker owns its partition", isShuffle: true},
226+
} {
227+
t.Run(test.name, func(t *testing.T) {
228+
proc := testutil.NewProcessWithMPool(t, "", mpool.MustNewZero())
229+
ch := make(chan *WorkerJoinMsg, 1)
230+
arg := &DedupJoin{
231+
NumCPU: 2,
232+
IsMerger: false,
233+
IsShuffle: test.isShuffle,
234+
Channel: ch,
235+
}
236+
237+
arg.Reset(proc, false, nil)
238+
239+
require.Equal(t, test.wantMessage, len(ch) == 1)
240+
proc.Free()
241+
})
242+
}
243+
}
244+
154245
func TestDedupPrepareFailureCanRetry(t *testing.T) {
155246
proc := testutil.NewProcessWithMPool(t, "", mpool.MustNewZero())
156247
typ := types.T_int32.ToType()

pkg/sql/colexec/dedupjoin/types.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,17 @@ func (dedupJoin *DedupJoin) Release() {
184184
}
185185
}
186186

187+
// needsFinalizeMerge reports whether parallel workers share one build map and
188+
// therefore must merge their matched state before a single worker emits the
189+
// build rows. Shuffle workers own disjoint build partitions, so each worker
190+
// must finalize and emit its partition independently.
191+
func (dedupJoin *DedupJoin) needsFinalizeMerge() bool {
192+
return dedupJoin.NumCPU > 1 && !dedupJoin.IsShuffle
193+
}
194+
187195
func (dedupJoin *DedupJoin) Reset(proc *process.Process, pipelineFailed bool, err error) {
188196
ctr := &dedupJoin.ctr
189-
if !ctr.handledLast && dedupJoin.NumCPU > 1 && !dedupJoin.IsMerger {
197+
if !ctr.handledLast && dedupJoin.needsFinalizeMerge() && !dedupJoin.IsMerger {
190198
dedupJoin.Channel <- nil
191199
}
192200
if dedupJoin.OpAnalyzer != nil {

pkg/sql/plan/query_builder.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2749,6 +2749,7 @@ func (builder *QueryBuilder) createQuery() (*Query, error) {
27492749
builder.optimizeDistinctAgg(rootID)
27502750
ReCalcNodeStats(rootID, builder, true, false, true)
27512751
builder.determineBuildAndProbeSide(rootID, true)
2752+
builder.disableMemoryUnsafeRightDedup(rootID)
27522753

27532754
builder.qry.Steps[i] = rootID
27542755

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright 2026 Matrix Origin
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package plan
16+
17+
import (
18+
"math"
19+
"testing"
20+
21+
"github.com/matrixorigin/matrixone/pkg/container/types"
22+
planpb "github.com/matrixorigin/matrixone/pkg/pb/plan"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestDisableMemoryUnsafeRightDedupUsesCombinedMapSize(t *testing.T) {
27+
const combinedMapBytes = 64*1024 + 128*1024
28+
29+
t.Run("combined maps fit", func(t *testing.T) {
30+
builder, joins := makeChainedRightDedupBuilder(combinedMapBytes)
31+
32+
builder.disableMemoryUnsafeRightDedup(4)
33+
34+
require.True(t, joins[0].IsRightJoin)
35+
require.True(t, joins[1].IsRightJoin)
36+
})
37+
38+
t.Run("combined maps exceed budget", func(t *testing.T) {
39+
builder, joins := makeChainedRightDedupBuilder(combinedMapBytes - 1)
40+
41+
builder.disableMemoryUnsafeRightDedup(4)
42+
43+
require.False(t, joins[0].IsRightJoin)
44+
require.False(t, joins[1].IsRightJoin)
45+
})
46+
}
47+
48+
func TestDisableMemoryUnsafeRightDedupHonorsRowThreshold(t *testing.T) {
49+
t.Run("combined keys fit", func(t *testing.T) {
50+
builder, joins := makeChainedRightDedupBuilder(2201)
51+
52+
builder.disableMemoryUnsafeRightDedup(4)
53+
54+
require.True(t, joins[0].IsRightJoin)
55+
require.True(t, joins[1].IsRightJoin)
56+
})
57+
58+
t.Run("combined keys reach threshold", func(t *testing.T) {
59+
builder, joins := makeChainedRightDedupBuilder(2200)
60+
61+
builder.disableMemoryUnsafeRightDedup(4)
62+
63+
require.False(t, joins[0].IsRightJoin)
64+
require.False(t, joins[1].IsRightJoin)
65+
})
66+
}
67+
68+
func TestDisableMemoryUnsafeRightDedupRejectsUnknownCardinality(t *testing.T) {
69+
tests := []struct {
70+
name string
71+
stats *planpb.Stats
72+
}{
73+
{name: "non-finite", stats: &planpb.Stats{Outcnt: math.NaN()}},
74+
{name: "default sentinel", stats: DefaultStats()},
75+
}
76+
for _, test := range tests {
77+
t.Run(test.name, func(t *testing.T) {
78+
builder, joins := makeChainedRightDedupBuilder(1 << 30)
79+
builder.qry.Nodes[0].Stats = test.stats
80+
81+
builder.disableMemoryUnsafeRightDedup(4)
82+
83+
require.False(t, joins[0].IsRightJoin)
84+
require.False(t, joins[1].IsRightJoin)
85+
})
86+
}
87+
}
88+
89+
func makeChainedRightDedupBuilder(joinSpillMem int64) (*QueryBuilder, []*planpb.Node) {
90+
source := &planpb.Node{NodeType: planpb.Node_VALUE_SCAN, Stats: &planpb.Stats{Outcnt: 1000}}
91+
targetPK := &planpb.Node{NodeType: planpb.Node_TABLE_SCAN, Stats: &planpb.Stats{Outcnt: 100}}
92+
pkDedup := &planpb.Node{
93+
NodeType: planpb.Node_JOIN,
94+
JoinType: planpb.Node_DEDUP,
95+
Children: []int32{1, 0},
96+
OnList: []*planpb.Expr{makeRightDedupEquality(types.T_int64)},
97+
OnDuplicateAction: planpb.Node_FAIL,
98+
IsRightJoin: true,
99+
Stats: &planpb.Stats{Outcnt: 100}, // stale pre-swap RIGHT DEDUP estimate
100+
}
101+
targetUnique := &planpb.Node{NodeType: planpb.Node_TABLE_SCAN, Stats: &planpb.Stats{Outcnt: 100}}
102+
uniqueDedup := &planpb.Node{
103+
NodeType: planpb.Node_JOIN,
104+
JoinType: planpb.Node_DEDUP,
105+
Children: []int32{3, 2},
106+
OnList: []*planpb.Expr{makeRightDedupEquality(types.T_varchar)},
107+
OnDuplicateAction: planpb.Node_FAIL,
108+
IsRightJoin: true,
109+
Stats: &planpb.Stats{Outcnt: 100},
110+
}
111+
return &QueryBuilder{
112+
qry: &planpb.Query{Nodes: []*planpb.Node{source, targetPK, pkDedup, targetUnique, uniqueDedup}},
113+
joinSpillMem: joinSpillMem,
114+
}, []*planpb.Node{pkDedup, uniqueDedup}
115+
}
116+
117+
func makeRightDedupEquality(typ types.T) *planpb.Expr {
118+
planType := planpb.Type{Id: int32(typ)}
119+
return &planpb.Expr{
120+
Typ: planpb.Type{Id: int32(types.T_bool)},
121+
Expr: &planpb.Expr_F{F: &planpb.Function{Args: []*planpb.Expr{
122+
{Typ: planType, Expr: &planpb.Expr_Col{Col: &planpb.ColRef{RelPos: 0, ColPos: 0}}},
123+
{Typ: planType, Expr: &planpb.Expr_Col{Col: &planpb.ColRef{RelPos: 1, ColPos: 0}}},
124+
}}},
125+
}
126+
}

pkg/sql/plan/shuffle.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,9 @@ func determineShuffleForJoin(node *plan.Node, builder *QueryBuilder) {
502502
}
503503
} else {
504504
rightChild := builder.qry.Nodes[node.Children[1]]
505-
if rightChild.Stats.Outcnt > 320000 {
506-
//dedup join always go hash shuffle, optimize this in the future
505+
if rightChild.Stats.Outcnt > 320000 && !dedupJoinUsesUnsupportedFloatShuffle(node) {
506+
// Large DEDUP joins normally use hash shuffle. FLOAT hash shuffle is
507+
// not supported yet, so those joins stay single-CN and spill locally.
507508
node.Stats.HashmapStats.Shuffle = true
508509
node.Stats.HashmapStats.ShuffleColIdx = 0
509510
node.Stats.HashmapStats.ShuffleType = plan.ShuffleType_Hash
@@ -626,6 +627,18 @@ func determineShuffleForJoin(node *plan.Node, builder *QueryBuilder) {
626627
}
627628
}
628629

630+
func dedupJoinUsesUnsupportedFloatShuffle(node *plan.Node) bool {
631+
if len(node.OnList) == 0 {
632+
return false
633+
}
634+
condition := node.OnList[0].GetF()
635+
if condition == nil || len(condition.Args) == 0 {
636+
return false
637+
}
638+
keyType := types.T(condition.Args[0].Typ.Id)
639+
return keyType == types.T_float32 || keyType == types.T_float64
640+
}
641+
629642
// find mergegroup or mergegroup->filter node
630643
func dontShuffle(node *plan.Node, builder *QueryBuilder) bool {
631644
if node.NodeType == plan.Node_AGG && !node.Stats.HashmapStats.Shuffle {

0 commit comments

Comments
 (0)