This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 0c0cd4e5 perf(parquet): use typed memo insertion for byte-array 
dictionaries (#1272)
0c0cd4e5 is described below

commit 0c0cd4e5ef6417f33d2fac2cd79838afd62df31a
Author: Derek Perkins <[email protected]>
AuthorDate: Wed Sep 2 10:35:50 2026 -0600

    perf(parquet): use typed memo insertion for byte-array dictionaries (#1272)
    
    ### Rationale for this change
    
    `DictByteArrayEncoder.PutByteArray` inserts through the untyped
    `MemoTable.GetOrInsert(interface{})`, which boxes the
    `parquet.ByteArray` on every value written. Boxing a slice is a heap
    allocation (`runtime.convTslice`), and the memo table discards it
    immediately:
    
    ```go
    // parquet/internal/encoding/byte_array_encoder.go
    func (enc *DictByteArrayEncoder) PutByteArray(in parquet.ByteArray) {
            memoIdx, found, err := enc.memo.GetOrInsert(in)   // convTslice per 
value
    ```
    
    `hashing.BinaryMemoTable` already implements the allocation-free typed
    entry point, and `GetOrInsert` is a thin boxing wrapper over it:
    
    ```go
    func (b *BinaryMemoTable) GetOrInsert(val interface{}) (int, bool, error) {
            return b.InsertOrGet(b.valAsByteSlice(val))
    }
    ```
    
    The encoder can't reach it, because `encoding.BinaryMemoTable` doesn't
    list `InsertOrGet` among its methods.
    
    This is the same change already made for the numeric paths in #1178 and
    #1251. #1178 notes that it "keep[s] the existing byte-array and
    fixed-length byte-array paths unchanged", so this is the remaining half
    of that work rather than a new direction.
    
    **Benchmark** — `BenchmarkEncodeDictByteArray`, already in the tree
    (65,535 values, 100 unique, 8–32 byte strings). `benchstat`, n=10, Apple
    M3 Max, Go 1.27, against `main` at `7efe1c0`:
    
    |  | before | after | change |
    | --- | ---: | ---: | ---: |
    | sec/op | 3.266m ± 1% | 2.495m ± 2% | **−23.61%** (p=0.000) |
    | B/op | 3.551Mi ± 0% | 2.051Mi ± 0% | **−42.26%** (p=0.000) |
    | allocs/op | 131.12k ± 0% | 65.58k ± 0% | **−49.98%** (p=0.000) |
    
    The allocation delta is exactly 65,536 — one per value, plus one.
    
    The benchmark understates the production effect, because its 100
    distinct values keep the memo table small. In a low-cardinality column
    the boxing dominates: every value allocates, and every value is then
    found to be a duplicate. We hit this writing Iceberg tables through
    `iceberg-go`, which writes via `pqarrow`. In a four-hour production CPU
    and heap profile of a single streaming writer,
    `DictByteArrayEncoder.PutByteArray` was the **fifth-largest allocation
    site in the whole process — 25.5M objects, 7.7% of everything
    allocated**, all of it this one site. `typedDictEncoder[int64].Put` was
    number one in the same profile at 15.1% before #1178 landed; together
    the two accounted for roughly a quarter of the process's allocations,
    which showed up as ~12% of CPU in GC mark.
    
    ### What changes are included in this PR?
    
    - Add `InsertOrGet(val []byte)` to the `encoding.BinaryMemoTable`
    interface.
    - Call it from `DictByteArrayEncoder.PutByteArray` instead of
    `GetOrInsert`.
    - Add `InsertOrGet` to `binaryMemoTableImpl` so it still satisfies the
    interface.
    - Add `TestBinaryInsertOrGet`, covering both implementations.
    
    Notes:
    
    - `encoding.BinaryMemoTable` lives under `parquet/internal/`, so
    widening it is not a public API change.
    - The production implementation (`hashing.BinaryMemoTable`, via
    `NewBinaryDictionary`) already satisfied the wider interface with no
    changes.
    - The only other implementation is `binaryMemoTableImpl`, which the
    source marks deprecated and benchmark-only ("will be removed in a future
    release"); the method added there mirrors its existing `GetOrInsert`.
    - `DictFixedLenByteArrayEncoder` has the same pattern. I left it out to
    keep this focused and because I have no production numbers for it —
    happy to follow up.
    
    ### Are these changes tested?
    
    Yes. `TestBinaryInsertOrGet` runs against both `BinaryMemoTable`
    implementations and checks index assignment, the `found` flag on
    re-insertion, `nil` treated as the empty value, agreement with
    `GetOrInsert`, and that stored values do not alias the caller's buffer.
    I confirmed the test fails when the implementation is deliberately
    broken.
    
    - `go build ./parquet/...`
    - `go vet ./parquet/internal/encoding/`
    - `go test ./parquet/internal/encoding/...` — pass
    - `go test -race ./parquet/internal/encoding/...` — pass
    - `go test ./parquet/pqarrow/... ./parquet/file/...` — the only failures
    are pre-existing and byte-identical to unpatched `main` (the
    `parquet-testing` submodule data is not checked out locally); verified
    by stashing the patch and re-running
    - `gofmt -l` clean, `git diff --check` clean
    
    Benchmark command:
    
    ```
    go test ./parquet/internal/encoding -run '^$' -bench 
'^BenchmarkEncodeDictByteArray$' -benchmem -benchtime=500ms -count=10
    ```
    
    ### Are there any user-facing changes?
    
    No. The modified interface is in an internal package, and encoded output
    is unchanged — only the insertion path differs.
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 parquet/internal/encoding/byte_array_encoder.go |  2 +-
 parquet/internal/encoding/memo_table.go         | 14 +++++
 parquet/internal/encoding/memo_table_test.go    | 69 +++++++++++++++++++++++++
 3 files changed, 84 insertions(+), 1 deletion(-)

diff --git a/parquet/internal/encoding/byte_array_encoder.go 
b/parquet/internal/encoding/byte_array_encoder.go
index cb80fbab..95ea3e11 100644
--- a/parquet/internal/encoding/byte_array_encoder.go
+++ b/parquet/internal/encoding/byte_array_encoder.go
@@ -106,7 +106,7 @@ func (enc *DictByteArrayEncoder) WriteDict(out []byte) {
 // PutByteArray adds a single byte array to buffer, updating the dictionary
 // and encoded size if it's a new value
 func (enc *DictByteArrayEncoder) PutByteArray(in parquet.ByteArray) {
-       memoIdx, found, err := enc.memo.GetOrInsert(in)
+       memoIdx, found, err := enc.memo.(BinaryMemoTable).InsertOrGet(in)
        if err != nil {
                panic(err)
        }
diff --git a/parquet/internal/encoding/memo_table.go 
b/parquet/internal/encoding/memo_table.go
index 062104b6..a0cab851 100644
--- a/parquet/internal/encoding/memo_table.go
+++ b/parquet/internal/encoding/memo_table.go
@@ -99,6 +99,9 @@ type TypedMemoTable[T hashing.MemoTypes] interface {
 // for handling byte arrays/strings/fixed length byte arrays.
 type BinaryMemoTable interface {
        MemoTable
+       // InsertOrGet is the typed equivalent of MemoTable.GetOrInsert, 
avoiding the
+       // interface boxing of the value on every call.
+       InsertOrGet(val []byte) (idx int, found bool, err error)
        // ValuesSize returns the total number of bytes needed to copy all of 
the values
        // from this table.
        ValuesSize() int
@@ -240,6 +243,17 @@ func (m *binaryMemoTableImpl) GetOrInsert(val interface{}) 
(idx int, found bool,
        return
 }
 
+func (m *binaryMemoTableImpl) InsertOrGet(val []byte) (idx int, found bool, 
err error) {
+       key := string(val)
+       idx, found = m.table[key]
+       if !found {
+               idx = m.Size()
+               m.builder.AppendString(key)
+               m.table[key] = idx
+       }
+       return
+}
+
 func (m *binaryMemoTableImpl) GetOrInsertNull() (idx int, found bool) {
        idx, found = m.GetNull()
        if !found {
diff --git a/parquet/internal/encoding/memo_table_test.go 
b/parquet/internal/encoding/memo_table_test.go
index 348cda76..fb2b27d4 100644
--- a/parquet/internal/encoding/memo_table_test.go
+++ b/parquet/internal/encoding/memo_table_test.go
@@ -291,3 +291,72 @@ func (m *MemoTableTestSuite) TestBinaryEmpty() {
        table.CopyOffsetsSubset(0, offsets)
        m.Equal(int32(0), offsets[0])
 }
+
+// InsertOrGet is the typed entry point used by the byte-array dictionary
+// encoders. It must agree with GetOrInsert on index assignment and on whether
+// the value already existed, for every BinaryMemoTable implementation.
+func (m *MemoTableTestSuite) TestBinaryInsertOrGet() {
+       const (
+               A = ""
+               B = "a"
+               C = "foo"
+               D = "\000"
+               E = "\000trailing"
+       )
+
+       for _, tt := range []struct {
+               name  string
+               table func() encoding.BinaryMemoTable
+       }{
+               {"hashing", func() encoding.BinaryMemoTable {
+                       return 
encoding.NewBinaryDictionary(memory.DefaultAllocator)
+               }},
+               {"legacy", func() encoding.BinaryMemoTable {
+                       return 
encoding.NewBinaryMemoTable(memory.DefaultAllocator)
+               }},
+       } {
+               m.Run(tt.name, func() {
+                       table := tt.table()
+                       defer table.Release()
+
+                       for idx, val := range []string{A, B, C, D, E} {
+                               got, found, err := 
table.InsertOrGet([]byte(val))
+                               m.Require().NoError(err)
+                               m.False(found, "value %q should be inserted, 
not found", val)
+                               m.Equal(idx, got)
+                       }
+                       m.Equal(5, table.Size())
+
+                       // Re-inserting must return the original index and 
report found.
+                       for idx, val := range []string{A, B, C, D, E} {
+                               got, found, err := 
table.InsertOrGet([]byte(val))
+                               m.Require().NoError(err)
+                               m.True(found, "value %q should already exist", 
val)
+                               m.Equal(idx, got)
+                       }
+                       m.Equal(5, table.Size())
+
+                       // A nil slice is the empty value, which was inserted 
first.
+                       got, found, err := table.InsertOrGet(nil)
+                       m.Require().NoError(err)
+                       m.True(found)
+                       m.Equal(0, got)
+
+                       // InsertOrGet and GetOrInsert must agree.
+                       got, found, err = table.GetOrInsert([]byte(C))
+                       m.Require().NoError(err)
+                       m.True(found)
+                       m.Equal(2, got)
+
+                       // The inserted value must not alias the caller's 
buffer.
+                       buf := []byte("mutable")
+                       inserted, _, err := table.InsertOrGet(buf)
+                       m.Require().NoError(err)
+                       buf[0] = 'X'
+                       again, found, err := 
table.InsertOrGet([]byte("mutable"))
+                       m.Require().NoError(err)
+                       m.True(found, "stored value must be a copy, not a view 
of the caller's slice")
+                       m.Equal(inserted, again)
+               })
+       }
+}

Reply via email to