v11-0001-Add-TwoStageTable.pm-a-Perl-helper-for-two-stage.patch

text/plain

Filename: v11-0001-Add-TwoStageTable.pm-a-Perl-helper-for-two-stage.patch
Type: text/plain
Part: 0
Message: Re: Improve the performance of Unicode Normalization Forms.

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v11-0001
Subject: Add TwoStageTable.pm, a Perl helper for two-stage lookup tables
File+
src/tools/TwoStageTable.pm 445 0
From a5e15c5ef7759ee811db2dcdd6ae8f3a7f9c128c Mon Sep 17 00:00:00 2001
From: Alexander Borisov <lex.borisov@gmail.com>
Date: Mon, 22 Dec 2025 14:44:00 +0300
Subject: [PATCH v11 1/3] Add TwoStageTable.pm, a Perl helper for two-stage
 lookup tables

Introduce a new build-time Perl module that generates compact C
lookup tables and accessor functions for sparse numeric key spaces.
---
 src/tools/TwoStageTable.pm | 445 +++++++++++++++++++++++++++++++++++++
 1 file changed, 445 insertions(+)
 create mode 100644 src/tools/TwoStageTable.pm

diff --git a/src/tools/TwoStageTable.pm b/src/tools/TwoStageTable.pm
new file mode 100644
index 00000000000..eed3e03b091
--- /dev/null
+++ b/src/tools/TwoStageTable.pm
@@ -0,0 +1,445 @@
+#----------------------------------------------------------------------
+#
+# TwoStageTable.pm
+#    Perl module for generating compact C tables and lookup functions
+#    optimized for sparse numeric key distributions.
+#
+# The module creates a two-stage table (Offset and Index) for numeric values.
+# This approach is well suited for data clustered into ranges with variable-
+# sized gaps between them — for example, Unicode code points and character
+# encodings.
+#
+#
+# How it works.
+#
+# Essentially, we split the key space into fixed-size ranges of N values
+# (where N = 1 << SHIFT).
+#
+# The module creates two tables:
+# 1. Offset
+#     Contains offsets for the table Index.
+#     Stores the beginning of the range for a number.
+#
+# 2. Index
+#     Stores fixed-size ranges (pages) one after another. Each cell contains
+#     the value returned by the user callback for the corresponding key —
+#     typically an index into a separate user-provided data table, but it
+#     can be any numeric value.
+#
+# Algorithm for obtaining a value for a given key (using SHIFT = 8 and the
+# example tables shown below):
+#     1. We have the key 0x42 (Unicode code point for 'B', i.e. 66).
+#     2. Calculate its index in the Offset table: 0x42 >> SHIFT
+#        (8 bit = 256) = 0. offset[0] = 256.
+#     3. After obtaining the offset of the range start in the Index table, we
+#        calculate the specific position among 256 values for the key 0x42:
+#        RANGE_MASK = (1 << SHIFT) - 1
+#        offset[0] + (cp & RANGE_MASK) = 256 + 66 = 322
+#        index[322] = 76 (the value pushed for 'B' is 0x42 + 10 = 76).
+#
+# The first range of the Index table (positions 0..RANGE_SIZE-1) is reserved
+# as a dummy range filled with the dummy value (0 by default; configurable
+# via the second argument to new()). Any Offset table entry that has not
+# been explicitly assigned holds the value 0 and therefore points to this
+# dummy range, making the lookup return the dummy value (acting like a
+# NULL result). The same dummy value is also returned by the lookup
+# function for keys whose top-level offset index is out of range.
+#
+# For example:
+#     use TwoStageTable;
+#
+#     my %data;
+#     my $tst = new TwoStageTable(8);
+#
+#     foreach my $id (0x41..0x5A, 0x61..0x7A) {
+#         $tst->push($id);
+#         $data{$id} = $id + 10;
+#     }
+#
+#     my ($offset, $index, $func) = $tst->generate(
+#         'latin_greek_table',
+#         'get_index',
+#         sub { $data{$_[0]} }
+#     );
+#
+#     print join("\n", $offset, $index, $func);
+#
+# Result:
+#
+# static const uint16 latin_greek_table_offset[2] =
+# {
+#     256, 0
+# };
+#
+# static const uint16 latin_greek_table_index[379] =
+# {
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 75, 76, 77,
+#     78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96,
+#     97, 98, 99, 100, 0, 0, 0, 0, 0, 0, 107, 108, 109, 110, 111, 112, 113, 114,
+#     115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129,
+#     130, 131, 132
+# };
+#
+# static uint16
+# get_index(char32_t cp)
+# {
+#     uint16 offset_idx, offset;
+#
+#     offset_idx = cp >> 8;
+#
+#     if (offset_idx > 1)
+#         return 0;
+#
+#     offset = latin_greek_table_offset[offset_idx];
+#
+#     return latin_greek_table_index[offset + (cp & 255)];
+# }
+#
+# We can balance the two tables by changing the SHIFT value, which controls
+# the trade-off between their sizes:
+#   - smaller SHIFT  → smaller pages, fewer empty slots within each page
+#                      (denser Index table), but more pages and therefore a
+#                      larger Offset table.
+#   - larger SHIFT   → bigger pages with more empty slots inside (sparser
+#                      Index table), but fewer pages and therefore a smaller
+#                      Offset table.
+#
+# For example, let's modify the example above:
+#     my $tst = new TwoStageTable(4); # Not 8, but 4.
+#
+# The result for the same numbers:
+#
+# static const uint16 latin_greek_table_offset[9] =
+# {
+#     0, 0, 0, 0, 16, 32, 48, 64, 0
+# };
+#
+# static const uint16 latin_greek_table_index[75] =
+# {
+#     0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 75, 76, 77, 78, 79, 80,
+#     81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99,
+#     100, 0, 0, 0, 0, 0, 0, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116,
+#     117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131,
+#     132
+# };
+#
+# static uint16
+# get_index(char32_t cp)
+# {
+#     uint16 offset_idx, offset;
+#
+#     offset_idx = cp >> 4;
+#
+#     if (offset_idx > 8)
+#         return 0;
+#
+#     offset = latin_greek_table_offset[offset_idx];
+#
+#     return latin_greek_table_index[offset + (cp & 15)];
+# }
+#
+# We can see that the tables have become more "balanced".
+#
+#
+# Disadvantages.
+#
+# The algorithm performs well when values are clustered into ranges with
+# moderate gaps between them (typical for Unicode property data and
+# character encodings). For example, values spread across ranges
+# 100..500, 1000..1200, 10000..30000, and so on — everything will be fine
+# in the algorithm.
+#
+# For example, in the worst case scenario, let's take two ranges of numbers:
+# 10..150 and 5000000..10000000. Tables with very large dummy data will be
+# constructed for these values. It seems that these problems can be solved,
+# but this does not apply to the current tasks of using this algorithm.
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/tools/TwoStageTable.pm
+#
+#----------------------------------------------------------------------
+
+package TwoStageTable;
+
+use strict;
+use warnings FATAL => 'all';
+use Text::Wrap qw(wrap);
+
+# new($range_shift, $dummy)
+#    Constructor for the sparse table generator object.
+#
+#    Initializes paging parameters and internal state used to build the
+#    two-level table (offset + index). If $range_shift is not provided,
+#    it defaults to 8, which gives a range size of 2^8 = 256 entries.
+#
+#    $dummy is the value used for empty Index-table slots and as the
+#    return value of the generated lookup function for keys outside the
+#    populated range. Defaults to 0.
+#
+#    Internal fields:
+#      keys              - array of all registered numeric keys
+#      max               - maximum key seen so far
+#      offset_table_size - size of the offset table (filled at generate())
+#      range_shift       - bit count, controls range granularity
+#      range_size        - number of positions in one range (1 << range_shift)
+#      range_mask        - bitmask for extracting low bits ($range_size - 1)
+#      dummy             - fill value for empty Index slots / out-of-range lookups
+sub new
+{
+	my ($class, $range_shift, $dummy) = @_;
+	my ($range_size, $range_mask);
+
+	$range_shift ||= 8;
+	$range_size = 1 << $range_shift;
+	$range_mask = $range_size - 1;
+	$dummy //= 0;
+
+	die "the dummy value must be an integer"
+	  unless $dummy =~ /^-?[0-9]+$/;
+
+	return bless {
+		keys => [],
+		max => 0,
+		index_uint => "uint16",
+		offset_uint => "uint16",
+		offset_table_size => 0,
+		range_shift => $range_shift,
+		range_size => $range_size,
+		range_mask => $range_mask,
+		dummy => $dummy
+	}, $class;
+}
+
+# push($key)
+#    Append a new numeric key in the generator state.
+#
+#    The key must be a non-negative integer; otherwise the function dies
+#    with an error message.
+sub push
+{
+	my ($tst, $key) = @_;
+
+	die "the key must be a number" unless $key =~ /^[0-9]+$/;
+
+	CORE::push @{ $tst->{keys} }, $key;
+}
+
+# generate($table_name, $func_name, $callback)
+#    Main generation routine that produces C tables and lookup function
+#    from all previously added keys.
+#
+#    return [Offset table, Index table, lookup function].
+sub generate
+{
+	my ($tst, $table_name, $func_name, $callback) = @_;
+	my (@sorted, @offsets, @data, $pos, $table_size);
+	my ($uint_offset);
+
+	die "no values for table generation and functions"
+	  unless scalar(@{ $tst->{keys} });
+
+	# It is not essential, but for consistent table output, it is better
+	# to sort the data.
+	@sorted = sort { $a <=> $b } @{ $tst->{keys} };
+	$table_size = (($sorted[-1] >> $tst->{range_shift}) + 1);
+
+	# We immediately allocate the required size for the table.
+	$offsets[$table_size] = 0;
+	$pos = $tst->{range_size};
+
+	foreach my $key (@sorted)
+	{
+		my $offset_index = $key >> $tst->{range_shift};
+		my $offset = $offsets[$offset_index];
+
+		unless (defined $offset)
+		{
+			$offset = $pos;
+			$offsets[$offset_index] = $offset;
+			$pos += $tst->{range_size};
+
+			$uint_offset = _uint_type($offset);
+		}
+
+		my $index = $key & $tst->{range_mask};
+		my $value = $callback->($key);
+
+		$data[ $offset + $index ] = $value;
+	}
+
+	# Pick the Index-table cell type from the actual data range, including
+	# $tst->{dummy} so the type is wide enough to hold it (e.g. dummy =
+	# 0xFFFF over uint8 data forces uint16). If any value is negative, fall
+	# back to a signed type that fits both extremes.
+	my @range = sort { $a <=> $b } grep { defined $_ } @data, $tst->{dummy};
+	my ($min, $max) = ($range[0], $range[-1]);
+
+	$tst->{offset_table_size} = $table_size;
+	$tst->{offset_uint} = $uint_offset;
+	$tst->{index_uint} = $min < 0 ? _int_type($min, $max) : _uint_type($max);
+
+	return ($tst->_table_offset(\@offsets, $table_name),
+			$tst->_table_index(\@data, $table_name),
+			$tst->function($func_name, $table_name));
+}
+
+sub function
+{
+	my ($tst, $func_name, $table_name) = @_;
+
+	my $offset_name = "$table_name\_offset";
+	my $index_name = "$table_name\_index";
+
+	return <<FUNCTION;
+/*
+ * Lookup for tables:
+ *     $offset_name and $index_name.
+ */
+static $tst->{index_uint}
+$func_name(char32_t cp)
+{
+	$tst->{offset_uint}		offset_idx,
+				offset;
+
+	offset_idx = cp >> $tst->{range_shift};
+
+	if (offset_idx > $tst->{offset_table_size})
+		return $tst->{dummy};
+
+	offset = $offset_name\[offset_idx];
+
+	return $index_name\[offset + (cp & $tst->{range_mask})];
+}
+FUNCTION
+}
+
+sub _table_offset
+{
+	my ($tst, $table, $name) = @_;
+
+	# Offset entries are structural pointers into the Index table; an
+	# unassigned entry must stay 0 so it points at the first (dummy) range
+	# regardless of the user's $tst->{dummy} value.
+	my $table_text =
+	  $tst->table($table, "$name\_offset", $tst->{offset_uint}, 0);
+
+	my $comment = <<COMMENT;
+/*
+ * The table contains offsets to table $name\_index.
+ */
+COMMENT
+	return "$comment$table_text";
+}
+
+sub _table_index
+{
+	my ($tst, $table, $name) = @_;
+
+	return $tst->table($table, "$name\_index", $tst->{index_uint},
+		$tst->{dummy});
+}
+
+# table($table, $name, $uint_type, $fill)
+#    Formats raw table data as pretty-printed C static array declaration.
+#    $fill is the value used to replace undefined slots in $table.
+sub table
+{
+	my ($tst, $table, $name, $uint_type, $fill) = @_;
+
+	my @values = map { defined $_ ? $_ : $fill } @$table;
+	my $length = scalar @values;
+
+	# Wrap the comma-separated values to ~80 columns with a 4-space indent.
+	local $Text::Wrap::columns = 80;
+	local $Text::Wrap::huge = 'overflow';
+
+	my $body = wrap("    ", "    ", join(", ", @values));
+
+	return join "\n",
+	  "static const $uint_type $name\[$length] =\n{",
+	  $body,
+	  "};";
+}
+
+# _uint_type($number)
+#    Internal function that determines the size of uint for a number.
+sub _uint_type
+{
+	my $num = 0 + $_[0];
+
+	# uint8: (1 << 8) - 1
+	if ($num <= (((1 << 7) - 1) | (1 << 7)))
+	{
+		return "uint8";
+	}
+
+	# uint16: (1 << 16) - 1
+	if ($num <= (((1 << 15) - 1) | (1 << 15)))
+	{
+		return "uint16";
+	}
+
+	# uint32: (1 << 32) - 1
+	if ($num <= (((1 << 31) - 1) | (1 << 31)))
+	{
+		return "uint32";
+	}
+
+	# uint64: (1 << 64) - 1
+	if ($num <= (((1 << 63) - 1) | (1 << 63)))
+	{
+		return "uint64";
+	}
+
+	die "value is greater than uint64: $num";
+}
+
+# _int_type($min, $max)
+#    Internal function that picks the smallest signed C type from
+#    {int8, int16, int32, int64} that fits both $min and $max.
+sub _int_type
+{
+	my ($min, $max) = @_;
+
+	# int8
+	if ($min >= -(1 << 7) && $max <= ((1 << 7) - 1))
+	{
+		return "int8";
+	}
+
+	# int16
+	if ($min >= -(1 << 15) && $max <= ((1 << 15) - 1))
+	{
+		return "int16";
+	}
+
+	# int32
+	if ($min >= -(1 << 31) && $max <= ((1 << 31) - 1))
+	{
+		return "int32";
+	}
+
+	# int64
+	if ($min >= -(1 << 63) && $max <= ((1 << 63) - 1))
+	{
+		return "int64";
+	}
+
+	die "value is out of int64 range: min=$min max=$max";
+}
+
+1;
-- 
2.39.5 (Apple Git-154)