From 01aaac04f1c7c1e4de9d0bb27bf2fb885e0954d8 Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Wed, 5 Nov 2025 22:32:13 -0800 Subject: [PATCH 1/4] table: performance fixes based on profiling --- Makefile | 2 +- cmd/profile-table/profile.go | 4 +- table/render.go | 2 +- table/render_init.go | 13 +++-- table/table.go | 2 + table/util.go | 40 +++++++++++++++ table/util_test.go | 96 ++++++++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 3bd19f32..5757ab22 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ bench: go test -bench=. -benchmem cyclo: - gocyclo -over 13 ./*/*.go + gocyclo -over 15 ./*/*.go demo-list: go run cmd/demo-list/demo.go diff --git a/cmd/profile-table/profile.go b/cmd/profile-table/profile.go index da8c3493..74c68d77 100644 --- a/cmd/profile-table/profile.go +++ b/cmd/profile-table/profile.go @@ -44,9 +44,9 @@ func main() { numRenders := 100000 if len(os.Args) > 1 { var err error - numRenders, err = strconv.Atoi(os.Args[2]) + numRenders, err = strconv.Atoi(os.Args[1]) if err != nil { - fmt.Printf("Invalid Argument: '%s'\n", os.Args[2]) + fmt.Printf("Invalid Argument: '%s'\n", os.Args[1]) os.Exit(1) } } diff --git a/table/render.go b/table/render.go index 4ef68f99..80d84dac 100644 --- a/table/render.go +++ b/table/render.go @@ -239,7 +239,7 @@ func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Bu } func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) { - out.WriteString(t.style.Format.Direction.Modifier()) + out.WriteString(t.directionModifier) if t.style.Options.DrawBorder { border := t.getBorderLeft(hint) colors := t.getBorderColors(hint) diff --git a/table/render_init.go b/table/render_init.go index 333c3988..5738642c 100644 --- a/table/render_init.go +++ b/table/render_init.go @@ -23,7 +23,7 @@ func (t *Table) analyzeAndStringify(row Row, hint renderHint) rowStr { } // convert each column to string and figure out if it has non-numeric data - rowOut := make(rowStr, len(row)) + rowOut := make(rowStr, len(row), len(row)) for colIdx, col := range row { // if the column is not a number, keep track of it if !hint.isHeaderRow && !hint.isFooterRow && !t.columnIsNonNumeric[colIdx] && !isNumber(col) { @@ -43,11 +43,15 @@ func (t *Table) analyzeAndStringifyColumn(colIdx int, col interface{}, hint rend } else if colStrVal, ok := col.(string); ok { colStr = colStrVal } else { - colStr = fmt.Sprint(col) + colStr = convertValueToString(col) } colStr = strings.ReplaceAll(colStr, "\t", " ") colStr = text.ProcessCRLF(colStr) - return fmt.Sprintf("%s%s", t.style.Format.Direction.Modifier(), colStr) + // Avoid fmt.Sprintf when direction modifier is empty (most common case) + if t.directionModifier == "" { + return colStr + } + return t.directionModifier + colStr } func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) { @@ -156,6 +160,9 @@ func (t *Table) initForRender() { // reset rendering state t.reset() + // cache the direction modifier to avoid repeated calls + t.directionModifier = t.style.Format.Direction.Modifier() + // initialize the column configs and normalize them t.initForRenderColumnConfigs() diff --git a/table/table.go b/table/table.go index 191dd771..77e9116f 100644 --- a/table/table.go +++ b/table/table.go @@ -28,6 +28,8 @@ type Table struct { // columnConfigMap stores the custom-configuration by column // number and is generated before rendering columnConfigMap map[int]ColumnConfig + // directionModifier caches the direction modifier string to avoid repeated calls + directionModifier string // firstRowOfPage tells if the renderer is on the first row of a page? firstRowOfPage bool // htmlCSSClass stores the HTML CSS Class to use on the node diff --git a/table/util.go b/table/util.go index 4636e881..9693dbd4 100644 --- a/table/util.go +++ b/table/util.go @@ -1,8 +1,10 @@ package table import ( + "fmt" "reflect" "sort" + "strconv" ) // AutoIndexColumnID returns a unique Column ID/Name for the given Column Number. @@ -26,6 +28,44 @@ func widthEnforcerNone(col string, _ int) string { return col } +// convertValueToString converts a value to string using fast type assertions +// for common numeric types before falling back to fmt.Sprint. +func convertValueToString(v interface{}) string { + switch val := v.(type) { + case int: + return strconv.FormatInt(int64(val), 10) + case int8: + return strconv.FormatInt(int64(val), 10) + case int16: + return strconv.FormatInt(int64(val), 10) + case int32: + return strconv.FormatInt(int64(val), 10) + case int64: + return strconv.FormatInt(val, 10) + case uint: + return strconv.FormatUint(uint64(val), 10) + case uint8: + return strconv.FormatUint(uint64(val), 10) + case uint16: + return strconv.FormatUint(uint64(val), 10) + case uint32: + return strconv.FormatUint(uint64(val), 10) + case uint64: + return strconv.FormatUint(val, 10) + case float32: + return strconv.FormatFloat(float64(val), 'g', -1, 32) + case float64: + return strconv.FormatFloat(val, 'g', -1, 64) + case bool: + if val { + return "true" + } + return "false" + default: + return fmt.Sprint(v) + } +} + // isNumber returns true if the argument is a numeric type; false otherwise. func isNumber(x interface{}) bool { if x == nil { diff --git a/table/util_test.go b/table/util_test.go index d176bca2..2bce7f44 100644 --- a/table/util_test.go +++ b/table/util_test.go @@ -52,6 +52,102 @@ func TestIsNumber(t *testing.T) { assert.False(t, isNumber(nil)) } +func Test_convertValueToString(t *testing.T) { + t.Run("int types", func(t *testing.T) { + assert.Equal(t, "0", convertValueToString(int(0))) + assert.Equal(t, "42", convertValueToString(int(42))) + assert.Equal(t, "-42", convertValueToString(int(-42))) + assert.Equal(t, "-128", convertValueToString(int8(-128))) + assert.Equal(t, "127", convertValueToString(int8(127))) + assert.Equal(t, "-32768", convertValueToString(int16(-32768))) + assert.Equal(t, "32767", convertValueToString(int16(32767))) + assert.Equal(t, "-2147483648", convertValueToString(int32(-2147483648))) + assert.Equal(t, "2147483647", convertValueToString(int32(2147483647))) + assert.Equal(t, "-9223372036854775808", convertValueToString(int64(-9223372036854775808))) + assert.Equal(t, "9223372036854775807", convertValueToString(int64(9223372036854775807))) + }) + + t.Run("uint types", func(t *testing.T) { + assert.Equal(t, "0", convertValueToString(uint(0))) + assert.Equal(t, "42", convertValueToString(uint(42))) + assert.Equal(t, "255", convertValueToString(uint8(255))) + assert.Equal(t, "0", convertValueToString(uint8(0))) + assert.Equal(t, "65535", convertValueToString(uint16(65535))) + assert.Equal(t, "4294967295", convertValueToString(uint32(4294967295))) + assert.Equal(t, "18446744073709551615", convertValueToString(uint64(18446744073709551615))) + }) + + t.Run("float types", func(t *testing.T) { + assert.Equal(t, "0", convertValueToString(float32(0))) + assert.Equal(t, "3.14", convertValueToString(float32(3.14))) + assert.Equal(t, "-3.14", convertValueToString(float32(-3.14))) + assert.Equal(t, "0", convertValueToString(float64(0))) + assert.Equal(t, "3.141592653589793", convertValueToString(float64(3.141592653589793))) + assert.Equal(t, "-3.141592653589793", convertValueToString(float64(-3.141592653589793))) + // Test scientific notation + assert.Contains(t, convertValueToString(float64(1e10)), "1e+10") + assert.Contains(t, convertValueToString(float32(1e10)), "1e+10") + }) + + t.Run("bool", func(t *testing.T) { + assert.Equal(t, "true", convertValueToString(bool(true))) + assert.Equal(t, "false", convertValueToString(bool(false))) + }) + + t.Run("default case - string", func(t *testing.T) { + assert.Equal(t, "hello", convertValueToString("hello")) + assert.Equal(t, "world", convertValueToString("world")) + assert.Equal(t, "", convertValueToString("")) + }) + + t.Run("default case - nil", func(t *testing.T) { + assert.Equal(t, "", convertValueToString(nil)) + }) + + t.Run("default case - slice", func(t *testing.T) { + assert.Equal(t, "[1 2 3]", convertValueToString([]int{1, 2, 3})) + assert.Equal(t, "[]", convertValueToString([]string{})) + }) + + t.Run("default case - map", func(t *testing.T) { + assert.Equal(t, "map[a:1]", convertValueToString(map[string]int{"a": 1})) + assert.Equal(t, "map[]", convertValueToString(map[string]int{})) + }) + + t.Run("default case - struct", func(t *testing.T) { + type testStruct struct { + Field1 string + Field2 int + } + result := convertValueToString(testStruct{Field1: "test", Field2: 42}) + assert.Contains(t, result, "test") + assert.Contains(t, result, "42") + }) + + t.Run("default case - pointer", func(t *testing.T) { + x := 42 + result := convertValueToString(&x) + // fmt.Sprint of a pointer shows the address, which includes "0x" + assert.Contains(t, result, "0x") + assert.NotEmpty(t, result) + }) + + t.Run("edge cases", func(t *testing.T) { + // Zero values + assert.Equal(t, "0", convertValueToString(int(0))) + assert.Equal(t, "0", convertValueToString(uint(0))) + assert.Equal(t, "0", convertValueToString(float32(0))) + assert.Equal(t, "0", convertValueToString(float64(0))) + assert.Equal(t, "false", convertValueToString(bool(false))) + + // Max values + assert.Equal(t, "127", convertValueToString(int8(127))) + assert.Equal(t, "255", convertValueToString(uint8(255))) + assert.Equal(t, "32767", convertValueToString(int16(32767))) + assert.Equal(t, "65535", convertValueToString(uint16(65535))) + }) +} + func Test_objAsSlice(t *testing.T) { a, b, c := 1, 2, 3 assert.Equal(t, "[1 2 3]", fmt.Sprint(objAsSlice([]int{a, b, c}))) From 3ce65c18851d856b76cdb4278f852f076feaa3c3 Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Wed, 5 Nov 2025 22:38:17 -0800 Subject: [PATCH 2/4] table: minor quality fixes --- table/render_bidi_test.go | 38 ++++++++++++++++++-------------------- table/sort.go | 2 +- table/table.go | 6 +++--- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/table/render_bidi_test.go b/table/render_bidi_test.go index 51d78250..b988af67 100644 --- a/table/render_bidi_test.go +++ b/table/render_bidi_test.go @@ -28,27 +28,25 @@ func TestTable_Render_BiDiText(t *testing.T) { +---+------------+------+--------+-----------+`) table.Style().Format.Direction = text.LeftToRight - compareOutput(t, table.Render(), ` -‪+---+------------+------+--------+-----------+ -‪| | ‪תאריך | ‪סכום | ‪מחלקה | ‪תגים | -‪+---+------------+------+--------+-----------+ -‪| 1 | ‪2020-01-01 | ‪5 | ‪מחלקה1 | ‪[תג1 תג2] | -‪| 2 | ‪2021-02-01 | ‪5 | ‪מחלקה1 | ‪[תג1] | -‪| 3 | ‪2022-03-01 | ‪5 | ‪מחלקה2 | ‪[תג1] | -‪+---+------------+------+--------+-----------+ -‪| | ‪סהכ | ‪30 | | | -‪+---+------------+------+--------+-----------+`) + compareOutput(t, table.Render(), "\u202A+---+------------+------+--------+-----------+\n"+ + "\u202A| | \u202Aתאריך | \u202Aסכום | \u202Aמחלקה | \u202Aתגים |\n"+ + "\u202A+---+------------+------+--------+-----------+\n"+ + "\u202A| 1 | \u202A2020-01-01 | \u202A5 | \u202Aמחלקה1 | \u202A[תג1 תג2] |\n"+ + "\u202A| 2 | \u202A2021-02-01 | \u202A5 | \u202Aמחלקה1 | \u202A[תג1] |\n"+ + "\u202A| 3 | \u202A2022-03-01 | \u202A5 | \u202Aמחלקה2 | \u202A[תג1] |\n"+ + "\u202A+---+------------+------+--------+-----------+\n"+ + "\u202A| | \u202Aסהכ | \u202A30 | | |\n"+ + "\u202A+---+------------+------+--------+-----------+") table.Style().Format.Direction = text.RightToLeft - compareOutput(t, table.Render(), ` -‫+---+------------+------+--------+-----------+ -‫| | ‫תאריך | ‫סכום | ‫מחלקה | ‫תגים | -‫+---+------------+------+--------+-----------+ -‫| 1 | ‫2020-01-01 | ‫5 | ‫מחלקה1 | ‫[תג1 תג2] | -‫| 2 | ‫2021-02-01 | ‫5 | ‫מחלקה1 | ‫[תג1] | -‫| 3 | ‫2022-03-01 | ‫5 | ‫מחלקה2 | ‫[תג1] | -‫+---+------------+------+--------+-----------+ -‫| | ‫סהכ | ‫30 | | | -‫+---+------------+------+--------+-----------+`) + compareOutput(t, table.Render(), "\u202B+---+------------+------+--------+-----------+\n"+ + "\u202B| | \u202Bתאריך | \u202Bסכום | \u202Bמחלקה | \u202Bתגים |\n"+ + "\u202B+---+------------+------+--------+-----------+\n"+ + "\u202B| 1 | \u202B2020-01-01 | \u202B5 | \u202Bמחלקה1 | \u202B[תג1 תג2] |\n"+ + "\u202B| 2 | \u202B2021-02-01 | \u202B5 | \u202Bמחלקה1 | \u202B[תג1] |\n"+ + "\u202B| 3 | \u202B2022-03-01 | \u202B5 | \u202Bמחלקה2 | \u202B[תג1] |\n"+ + "\u202B+---+------------+------+--------+-----------+\n"+ + "\u202B| | \u202Bסהכ | \u202B30 | | |\n"+ + "\u202B+---+------------+------+--------+-----------+") // sonar: ignore to here } diff --git a/table/sort.go b/table/sort.go index 7a47765a..7356f51a 100644 --- a/table/sort.go +++ b/table/sort.go @@ -63,7 +63,7 @@ func (t *Table) getSortedRowIndices() []int { sortedIndices[idx] = idx } - if t.sortBy != nil && len(t.sortBy) > 0 { + if len(t.sortBy) > 0 { sort.Sort(rowsSorter{ rows: t.rows, sortBy: t.parseSortBy(t.sortBy), diff --git a/table/table.go b/table/table.go index 77e9116f..c4127d44 100644 --- a/table/table.go +++ b/table/table.go @@ -307,12 +307,12 @@ func (t *Table) SetRowPainter(painter interface{}) { t.rowPainterWithAttributes = nil // if called as SetRowPainter(RowPainter(func...)) - switch painter.(type) { + switch p := painter.(type) { case RowPainter: - t.rowPainter = painter.(RowPainter) + t.rowPainter = p return case RowPainterWithAttributes: - t.rowPainterWithAttributes = painter.(RowPainterWithAttributes) + t.rowPainterWithAttributes = p return } From 0ef9ccedcb0697236af2d3c7f91cab75682f1ec3 Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Thu, 6 Nov 2025 08:32:54 -0800 Subject: [PATCH 3/4] table: manage type conversions for string --- table/render_init.go | 2 +- table/util.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/table/render_init.go b/table/render_init.go index 5738642c..f4c58170 100644 --- a/table/render_init.go +++ b/table/render_init.go @@ -23,7 +23,7 @@ func (t *Table) analyzeAndStringify(row Row, hint renderHint) rowStr { } // convert each column to string and figure out if it has non-numeric data - rowOut := make(rowStr, len(row), len(row)) + rowOut := make(rowStr, len(row)) for colIdx, col := range row { // if the column is not a number, keep track of it if !hint.isHeaderRow && !hint.isFooterRow && !t.columnIsNonNumeric[colIdx] && !isNumber(col) { diff --git a/table/util.go b/table/util.go index 9693dbd4..b4179a20 100644 --- a/table/util.go +++ b/table/util.go @@ -30,6 +30,8 @@ func widthEnforcerNone(col string, _ int) string { // convertValueToString converts a value to string using fast type assertions // for common numeric types before falling back to fmt.Sprint. +// +//gocyclo:ignore func convertValueToString(v interface{}) string { switch val := v.(type) { case int: @@ -61,6 +63,8 @@ func convertValueToString(v interface{}) string { return "true" } return "false" + case string: + return val default: return fmt.Sprint(v) } From a60ad5a8342efb7704aef33fed4d78b2d67ca82b Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Thu, 6 Nov 2025 19:21:35 -0800 Subject: [PATCH 4/4] undo Makefile change --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5757ab22..3bd19f32 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ bench: go test -bench=. -benchmem cyclo: - gocyclo -over 15 ./*/*.go + gocyclo -over 13 ./*/*.go demo-list: go run cmd/demo-list/demo.go