Тип numeric существует в Postgres уже более 25 лет. С тех пор он претерпел массу оптимизаций и доработок. Однако до сих пор операции над этим типом (в особенности операции агрегации) выглядят не очень неэффективными.

Поскольку сообщество разработчиков PostgreSQL обычно решает проблемы, для которых существует более-менее простое и очевидное решение, давайте разберёмся, есть ли у типа numeric какие-то структурные ограничения, которые не дают операторам СУБД для этого типа работать быстрее.

А для того, чтобы исследование было более наглядным, давайте проследим, как те же задачи решает DuckDB — благо AI-агенты сделали анализ исходного кода и тестирование сильно проще.

Здесь стоит помнить, что DuckDB предназначен исключительно для OLAP-запросов. А значит, как было отмечено ранее, он предъявляет к точности операций более слабые требования — и, как вы увидите далее, активно этим пользуется для повышения эффективности выполнения запросов.

Содержание

Затравка

Чтобы не быть голословным, обращу ваше внимание на два EXPLAIN ниже. Первый — группировка строк таблицы по набору из 12 колонок типа numeric.

 Finalize HashAggregate  (actual time=4320 rows=591894 loops=1)
   Group Key: _q_000_f_010rref, _q_000_f_008_rrref, ...
   Batches: 1  Memory Usage: 1835033kB
   Buffers: shared hit=111632
   ->  Gather  (actual time=1441 rows=1582591 loops=1)
         Buffers: shared hit=111632
         ->  Partial HashAggregate  (actual time=868 rows=263765.17 loops=6)
               Group Key: _q_000_f_010rref, _q_000_f_008_rrref, ...
               Batches: 1  Memory Usage: 704537kB
               ->  Parallel Seq Scan on tt4 t2  (actual time=68 rows=500000.00 loops=6)
 Execution Time: 4512.880 ms

Второй — ровно тот же самый запрос, но только колонки имеют тип double precision: в интересах теста мы пренебрегли точностью.

 Finalize HashAggregate  (actual time=1553 rows=591894 loops=1)
   Group Key: _q_000_f_008_type, _q_000_f_008_rtref, ...
   Batches: 1  Memory Usage: 196641kB
   Buffers: shared hit=125000
   ->  Gather  (actual time=596 rows=1577001 loops=1)
         Buffers: shared hit=125000
         ->  Partial HashAggregate  (actual time=337 rows=262833 loops=6)
               Group Key: _q_000_f_008_type, _q_000_f_008_rtref, ...
               Batches: 1  Memory Usage: 90145kB
               Buffers: shared hit=125000
               ->  Parallel Seq Scan on tt4_dbl t2  (actual time=28 rows=500000 loops=6)
 Execution Time: 1620.060 ms

Ускорение практически в три раза! При этом можно заметить, что вариант с double precision потребовал просканировать больше дисковых страниц (125 тыс. против 111 тыс.). Однако агрегация с numeric требует в 9 раз больше памяти. В чём причина такого негативного влияния на производительность после 25+ лет оптимизации этого типа? Нельзя ли его как-то сократить или совсем нивелировать? Давайте копнём матчасть и попытаемся понять, в чём дело.

Казалось бы, вопрос в десятичной арифметике. Однако три независимые попытки сделать быстрый десятичный тип расширением — pgDecimal Pavel Stehule, pgdecimal2 Feng Tian и fixeddecimal от 2ndQuadrant — заглохли, хотя арифметику каждая из них ускоряла. Значит, дело не только в арифметике, и ситуация чуть сложнее.

Четыре решения, за которые Postgres платит

Прежде чем погружаться в изучение сложной конструкции, полезно увидеть проблему целиком.

Точный десятичный тип есть у всех известных СУБД, и те, у кого он быстрый, сделали его быстрым очень похожим способом: значение хранится как обычное целое, а запятая — отдельно, в описании колонки. Так (вероятно) устроены DECIMAL в SQL Server, DuckDB и ClickHouse, decimal в Arrow и Parquet. PostgreSQL пошёл другим путём — сознательно приняв следующие ключевые решения:

  1. Представление не зависит от объявления. Все прочие выбирают ширину значения по объявленной точности: узкое число — два-четыре байта, широкое — восемь или шестнадцать. В PostgreSQL ширина — свойство типа, а не колонки, поэтому numeric обязан быть переменной длины.

  2. Масштаб живёт в значении, а не в описании колонки. Поэтому 1.5 и 1.50 различаются даже в numeric колонке, для которой не специфицируется масштаб.

  3. Масштаб результата вычисляется из данных, а не заранее. Сколько знаков даст деление, зависит от самих чисел, а не только от их типов.

  4. Гибкая верхняя граница точности. Формально предел у numeric есть — 131072 знака до запятой и 16383 после, — но ни одно практическое значение к нему не приближается: место под результат отводится по тому, что получилось, а не по объявлению и получить ошибку переполнения в рантайме практически невозможно.

По отдельности каждое решение выглядит разумно, и почти каждое продиктовано требованиями к точности вычислений, характерных для различных индустрий. Но у каждого есть цена, и дальше — про то, из чего эта цена складывается. Каждый следующий раздел разбирает один пункт этого списка: представление — первый, передача по значению и разбор кортежа — его последствия, масштаб промежуточного результата — второй и третий, цена десятичности — то, что остаётся, даже если отказаться от всех четырёх.

Сравнивать я буду с популярной СУБД DuckDB в основном для контраста. Потому, что он принял ровно противоположные решения по всем четырём пунктам, и на контрасте видно, где именно Postgres теряет производительность. Ну и да, его код можно свободно читать и анализировать.

Краткий словарь PostgreSQL Internals

Термин

Значение

varlena

значение переменной длины: сначала заголовок с длиной, потом данные

детоаст

распаковка значения перед тем, как с ним работать

Datum

машинное слово, в котором значение путешествует по исполнителю запроса

typmod

объявленные в схеме точность и масштаб, вроде (15,2)

dscale

сколько знаков после запятой печатать — хранится в самом значении

deform

разбор строки таблицы на отдельные колонки

Внутреннее устройство точного десятичного типа

Реализация numeric в PostgreSQL

Процессоры общего назначения считают в двоичной системе, а 0.1 в двоичной системе — бесконечная периодическая дробь, ровно как 1/3 в десятичной. Поэтому double precision хранит не 0.1, а ближайшее представимое двоичное число, и знаменитое 0.1 + 0.2 даёт 0.30000000000000004.

Однако деньги (метры кабеля на складе, тарифы ЖКХ) считаются в десятичной системе: копейки, процентные ставки и правила округления записаны в десятичных знаках. Точный десятичный тип нужен именно за этим — чтобы 0.1 было ровно 0.1, а округление шло по тому знаку, о котором говорит регламент.

Отсюда первое решение: цифры хранятся десятичные. Само по себе оно ещё не диктует ни ширину значения, ни место запятой — DECIMAL в DuckDB тоже десятичный и при этом фиксированной ширины, — но именно с него начинается устройство numeric.

Цифры, но не по одной. Наивно было бы отдать по байту на цифру (хотя артефакт такого представления можно найти в коде PostgreSQL): в байт влезает число до 255, а мы писали бы туда 0–9. PostgreSQL берёт два байта на группу и хранит в ней число от 0 до 9999. Получается позиционная запись по основанию 10000 — такая же, как привычная запись по основанию 10, только «цифр» не десять, а десять тысяч.

Почему именно 10000? Связано это с алгоритмом умножения «столбиком»: произведение двух «цифр» обязано влезать в int, и чисто арифметически подошло бы любое чётное основание меньше sqrt(INT_MAX) ≈ 46341. Из них выбирают степень десяти — с ней и печать, и округление до десятичного знака остаются тривиальными, — а наибольшая такая степень как раз 10000.

Вычисление начала дробной части. Хранить позицию запятой как «столько-то знаков от начала» неудобно: у очень больших и очень маленьких чисел набегали бы длинные цепочки нулей. Вместо этого хранится вес — номер разряда самой первой «цифры», считая в степенях 10000. Значение числа восстанавливается по следующей формуле:

значение = digit[0]·10000^weight + digit[1]·10000^(weight−1) + digit[2]·10000^(weight−2) + …

Например, для 123456.00 это выглядит так:

цифры:      12        3456
степень:  10000¹  ·  10000⁰            вес = 1
          120000  +    3456   =  123456

Выгода видна на маленьких числах: 0.0000000012 — это одна «цифра» 1200 и вес −3, а не гора нулей.

Как это печатать: dscale. Тут начинается непривычное. Числа 1.5 и 1.50 равны, но печатаются по-разному, а «цифры» у них одни и те же. Значит, различие надо хранить отдельно — для этого есть dscale, означающий «сколько знаков после запятой показывать»:

1.5     цифры: [1, 5000]   вес: 0   dscale: 1   →  печатаем «1.5»
1.50    цифры: [1, 5000]   вес: 0   dscale: 2   →  печатаем «1.50»

Побайтово это разные значения, а сравнение обязано считать их равными. Отсюда проистекают разные сложности. Например, сравнение и хэш обязаны dscale игнорировать, а печать обязана его помнить.

Заметим, вышесказанное верно для значения, объявленного как numeric. В колонке numeric(15,2) приведение при записи выставит обоим значениям dscale = 2, и они станут побайтово одинаковыми.

Знак и особые значения. Отдельного места под знак нет — он спрятан в двух старших битах заголовка вместе с признаком формата:

00 → положительное      NUMERIC_POS
01 → отрицательное      NUMERIC_NEG
10 → короткий формат    NUMERIC_SHORT
11 → особое значение    NUMERIC_SPECIAL — NaN, +Infinity, −Infinity

У особых значений цифр нет вовсе — остаётся только двухбайтовый заголовок (три байта в кортеже, шесть отдельным значением). И как бонус: нули по краям числа отбрасываются, 1.0000 хранится как одна «цифра» 1 при dscale = 4.

Упаковка. Существует так называемый упакованный формат хранения числа типа numeric. Если количество цифр незначительно (не более ~62), то используется однобайтовый заголовок длины и значение хранится без выравнивания. Для более длинных чисел используется стандартный, четырёхбайтный заголовок. Каждое конкретное значение может быть в одном из этих двух форматов. Операторы, работающие с numeric, рассчитывают на стандартный заголовок, поэтому любая функция должна быть готова в любой момент выполнить «распаковку» поступившего числа с однобайтным заголовком в стандартное представление.

Понятно, что в большинстве практических применений числа короткие. Поэтому операция распаковки обычно выполняется на каждое число — а это означает в том числе динамическую аллокацию дополнительной памяти плюс копирование.

Позитивный выхлоп здесь такой: небольшие числа numeric бывает выгоднее хранить, чем даже bigint, — их «упакованное» представление может быть прилично короче восьми байт:

CREATE TABLE t(x numeric(15,2));
INSERT INTO t VALUES (123456.00);

SELECT pg_column_size(x) FROM t;              -- 7
SELECT pg_column_size(123456.00::numeric);    -- 10

Как итог, значение numeric — маленькая самодостаточная структура: признак формата, знак, вес, масштаб и цепочка «цифр» по основанию 10000. Она умеет рассказать о себе всё — и чему равна, и как её печатать, — и потому может лежать в колонке, о которой не объявлено ничего, кроме слова numeric.

Это, с одной стороны, говорит о надёжности и возможности контролировать целостность данных, с другой — о дополнительных расходах на хранение и обработку. Чтобы понять, как могло быть по-другому, давайте посмотрим на подход DuckDB, который в силу иной области применения может быть более агрессивным в реализации точного десятичного типа.

Реализация DECIMAL в DuckDB

Имея ту же отправную точку, что и PostgreSQL — нужны точные десятичные значения, — DuckDB пошёл путём хранения десятичных чисел как целых. DECIMAL(15,2) со значением 123456.00 хранится как целое 12345600. И всё. Ни цифр по основанию 10000, ни веса, ни dscale:

значение = целое / 10^scale

Запятая существует только в момент печати. Формулировка из документации MonetDB, откуда эта конструкция и пришла в DuckDB:

«The decimal types are represented as fixed length integers, whose decimal point is produced during result rendering».

Масштаб, таким образом, становится свойством типа колонки. Проверим на живом DuckDB:

select typeof(1.5), typeof(1.50), typeof(1.500);
--     DECIMAL(2,1)  DECIMAL(3,2)  DECIMAL(4,3)

Различие, которое PostgreSQL держит в dscale внутри значения, DuckDB держит в имени типа. Разные записи одного числа — это разные типы, а не разные байты. Следствие видно сразу, стоит положить их в одну колонку:

CREATE TABLE t AS SELECT * FROM (VALUES (1.5),(1.50),(1.500)) v(x);
-- тип колонки: DECIMAL(4,3)
-- значения:    '1.500', '1.500', '1.500'

Получается, что у колонки один тип, значит, один масштаб и одна форма печати. Те же три значения в колонке numeric сохранили бы dscale 1, 2 и 3 и напечатались бы по-разному.

Таким образом, можно заранее выбирать оптимальный формат представления числа — и DuckDB активно этим пользуется, реализовав лестницу носителей: INT16, INT32, INT64, INT128 для точности до 4, 9, 18 и 38 цифр. Лестница описана в документации и задана в коде одним набором специализаций, decimal.hpp:

«Internally, decimals are represented as integers depending on their specified WIDTH»

Выбор делается один раз при планировании запроса, дальше в цикле работает мономорфная функция над конкретным целым. Ширину видно и снаружи: при выгрузке в Parquet DECIMAL(15,2) пишется физическим типом INT64, а DECIMAL(20,2) — уже байтовым массивом. Границы 18 и 38 у Parquet и DuckDB совпадают не случайно: обе системы упираются в одни и те же машинные целые.

Как результат, число хранится в обычном байтовом представлении. И хотя такое число может занимать больше места, чем упакованный numeric, базовая арифметика (сложение и умножение) для него сильно проще.

Однако фиксированный формат означает жёсткий потолок по максимальному значению. И, видимо по этой причине, чтобы достичь стандартных для индустрии 38 знаков, в DuckDB отсутствуют спецзначения «NaN», «+Infinity», «−Infinity». Превышение потолка в 38 цифр означает ошибку в рантайме.

Передача по значению или по ссылке

Внутри PostgreSQL любое значение путешествует в виде Datum — это машинное слово, восемь байт на 64-битной платформе. Если тип помещается в эти восемь байт, его передают по значению: bigint живёт прямо в Datum, то есть фактически в регистре процессора. Если не помещается — передают указатель. Тип становится pass-by-reference.

Значение типа numeric всегда передаётся по ссылке. А значит, результат каждой арифметической операции над numeric нужно куда-то положить, что подразумевает аллокацию памяти на каждую операцию. Сравним, что это означает для операции сложения:

bigint:   a + b  →  одна инструкция, результат в регистре

numeric:  a + b  →  выровнять масштабы
                 →  сложить
                 →  проверить пределы
                 →  palloc под результат
                 →  записать результат в память

При этом, даже если ограничить реализацию numeric и сохранить саму идею подхода — большой масштаб и гибкая граница промежуточного результата, — в восемь байт значение всё равно не влезет и по значению передаваться не начнёт. Ровно это препятствие заметил Thomas Munro в 2017 году, рассуждая про DECFLOAT в треде «Decimal64 and Decimal128»:

«DECFLOAT(9) [= 32 bit] and DECFLOAT(17) [= 64 bit] could in theory be passed by value. Of course we don’t have a way to make those pass-by-value and yet pass DECFLOAT(34) [= 128 bit] by reference! That is where I got stuck last time I was interested in this subject, because that seems like the place where we would stand to gain a bunch of performance, and yet the limited technical factors seems to be very well baked into Postgres».

DuckDB выбрал иной путь. Физический носитель выбирается по объявленной ширине колонки.

ширина

носитель

байт

1–4

INT16

2

5–9

INT32

4

10–18

INT64

8

19–38

INT128

16

При точности до 18 цифр значение попадает в машинное целое: ни Datum, ни указателя, ни palloc. Насколько это дёшево, видно из замера на 20 млн строк (M4 Pro, один поток): SUM по DECIMAL(18,2) — 8.7 мс, по BIGINT — 8.5 мс. Отношение 1.02: надбавки за десятичность нет вовсе, масштаб живёт в каталоге, а в рантайме это обычный int64.

Однако уже на DECIMAL(19,2) на ровно тех же данных бенчмарк показывает ~880 мс. Скачок в 100 раз на границе 18/19 цифр. То есть платят не за десятичную логику, а за ширину значения. Таким образом, DuckDB в этом случае быстр в основном потому, что у него в базе int64.

В PostgreSQL длина типа и способ передачи (по ссылке или по значению) — это характеристика типа, прописанная в системном каталоге, а не свойство колонки. Поэтому лестницу хранения внутри numeric в текущей архитектуре построить нельзя в принципе.

Разбор кортежа

Прежде чем что-то сравнивать, значения нужно достать из строки. Эта операция называется deform, и для numeric она тоже дороже.

Строка в PostgreSQL — это заголовок, битовая карта NULL-ов и дальше значения колонок подряд, без разделителей. Чтобы добраться до двадцатой колонки, нужно знать её смещение от начала. Если все колонки фиксированной ширины, смещение считается арифметически и кэшируется в дескрипторе таблицы — один раз на всё время жизни:

20 колонок int4 — смещения известны заранее

┌────┬────┬────┬────┬────┬─── … ───┬────┐
│ c1 │ c2 │ c3 │ c4 │ c5 │         │c20 │
└────┴────┴────┴────┴────┴─── … ───┴────┘
  0    4    8   12   16              76

смещение c20 = 19 × 4 — посчитано и кэшировано однократно.

Если ширина переменная, так нельзя. Правило записано прямо в коде, который строит описатель таблицы: кэшировать смещения только до первой колонки нефиксированной длины. Комментарий там так и звучит — «не кэшируем смещения дальше атрибутов фиксированной ширины». А это ровно случай numeric. Кэш смещений обрывается на первой такой колонке, и дальше каждое смещение приходится вычислять заново, читая заголовок каждого предыдущего значения:

20 колонок numeric — у каждого значения своя длина

┌──────┬─────┬────────┬──────┬─── … ───┬─────┐
│  c1  │ c2  │   c3   │  c4  │         │ c20 │
└──────┴─────┴────────┴──────┴─── … ───┴─────┘
  0      7     12       21              ???

чтобы узнать смещение c20, надо прочитать заголовки c1…c19 — на каждой строке заново.

Это, кстати, тот аспект, над которым сейчас идёт активная работа в ядре, хотя и с другой стороны. David Rowley потратил на deform два цикла разработки: коммит d28dff3f (PostgreSQL 18) заменил в описателе таблицы 104-байтовый FormData_pg_attribute на 16-байтовый CompactAttribute и дал «~10 % TPS на OLAP-агрегации по таблице из 16 полей, до ~25 %» за счёт того, что при разборе трогается меньше кэш-линий. Продолжение — «More speedups for tuple deformation» — закоммичено в PostgreSQL 19: в среднем 21 %, и до 44 %. Показательно, что половина тестовых случаев в этом бенчмарке отличается ровно одним: первая колонка — INT или TEXT. То есть влияние колонки переменной длины на производительность замечается в сообществе.

Но саму причину это не убирает. Переменная длина — прямое следствие решения ещё 1998 года, что numeric обязан вмещать числа произвольной точности. Другими словами, сделав numeric типом постоянной длины, мы могли бы немного удешевить обход кортежа.

В DuckDB понятия deform’а кортежа нет вообще, поскольку хранение колоночное. Значение адресуется индексом в массиве, и никаких смещений вычислять не надо. Однако некоторая аналогия этой проблемы есть и у них. При включённой компрессии (а она включена по умолчанию) в DuckDB наблюдается значительное отличие в сканировании колонок DECIMAL(18,2) и DECIMAL(19,2). На запросе:

count(*) where v > 5.00

среднее время выполнения составляет 5–7 мс для «узкого» и ~380 мс для «широкого» варианта хранения. Если же компрессию отключить (SET force_compression='uncompressed'), этот гэп исчезает. Поскольку арифметики здесь практически нет, то единственное, что может играть роль, — это распаковка int128, которая оказывается достаточно дорогой операцией.

То есть в обоих движках доставка значения к операции стоит дороже самой операции. У Postgres это deform, у них — декомпрессия; общее в том, что платится за ширину и за формат хранения, а не за арифметику.

Зато целочисленное представление открывает доступ к быстрым операциям, и результат выходит контринтуитивный — его стоит держать в голове всякий раз, когда кто-то советует «взять float, он быстрее». Плюс к тому, на decimal-колонках DuckDB включается BitPacking, а на DOUBLE — более дорогой ALP.

Таким образом, «точность стоит ресурсов» — это скорее свойство конкретной реализации, а не свойство точной десятичной арифметики.

Масштаб промежуточного результата

Правила упаковки, представления и хранения мы разобрали. Однако в плане запроса исходные значения используются только как исходные данные для операций, размерность и масштаб которых могут существенно отличаться от исходных. Это в свою очередь определяет вычислительные затраты и потребное количество дополнительной памяти для хранения промежуточных результатов. Как наши подопечные СУБД решают этот вопрос?

Управление точностью промежуточных результатов в numeric

У bigint всё просто: результат операции над двумя bigint — либо bigint, либо ошибка переполнения. Третьего не дано, поэтому и проверка ровно одна — флаг переполнения процессора.

С numeric так не получится:

  • умножение двух чисел по 32 значащих цифры даёт до 64 цифр;

  • деление вообще может не заканчиваться — 1/3 в десятичной записи бесконечно.

Значит, у каждой операции должна быть политика округления. А округление ломает привычные алгебраические свойства: сумма остаётся ассоциативной, а вот произведение после округления — уже нет. Отсюда ограничения на переупорядочивание операций в агрегатах и на распараллеливание.

Масштаб результата арифметических операций в PostgreSQL выводится из данных следующим образом:

select 1.5 + 1.50;    -- 3.00        (2 знака)
select 2.0 * 3.00;    -- 6.000       (3 знака)
select 10.0 / 4;      -- 2.5000000000000000   (16 знаков)

Здесь он соответствует стандарту SQL — у сложения масштаб результата равен максимуму из масштабов аргументов, у умножения — сумме. А что с делением?

select 1 / 3::numeric;         -- 0.33333333333333333333    (20 знаков)
select 1000000 / 3::numeric;   -- 333333.333333333333       (12 знаков)
select 0.001 / 3::numeric;     -- 0.00033333333333333333    (20 знаков)

Количество знаков после запятой разное, хотя тип аргументов один и тот же. Масштаб для операции деления выбирается так, чтобы значащих цифр было не меньше 16.

Комментарий из исходников PostgreSQL

The result scale of a division isn’t specified in any SQL standard. For PostgreSQL we select a result scale that will give at least NUMERIC_MIN_SIG_DIGITS significant digits, so that numeric gives a result no less accurate than float8; but use a scale not less than either input’s display scale.

Почему масштаб промежуточных результатов вообще может быть важен? Я стал заинтересоваться этим аспектом после статьи «The FastLanes Compression Layout», PVLDB, 2023 и в частности, следующей сентенции:

«We think scans in next-gen database systems should not decompress columns eagerly to their SQL type, which often is a wide integer (e.g., a decimal stored in 64-bits), but rather to the smallest type that makes the values processable by query operators».

То есть возможно, что специализированное внутреннее представление значений в executor’e может потенциально дать профит как по памяти, так и за счет использования эффективных арифметических операций. Учитывая количество переходных состояний в сложном дереве запроса, эффект может быть значительным.

Поскольку каждое конкретное значение колонки в PostgreSQL имеет свой собственный масштаб, то и результат арифметической операции определяется каждый раз динамически, во время выполнения. На этапе планирования можно попытаться предсказать максимум по точности и масштабу для операций сложения и умножения, однако для остальных операций это достаточно затруднительно: оценка сверху даёт чересчур большие числа. При этом зафиксировать единый масштаб для результата каждой операции заранее Postgres не может — ему не позволяет этого семантика numeric:

select 1.0 = 1.00;                              -- true
select (1.0)::text = (1.00)::text;              -- false
select hash_numeric(1.0) = hash_numeric(1.00);  -- true

Зафиксировав масштаб, мы потеряем различие. Для большинства приложений это может оказаться несущественно, но в любом случае такое «прибивание гвоздями» масштаба может быть сделано уже только в рамках другого, нового типа данных.

Таким образом, неопределённость масштаба приводит к дополнительным накладным расходам: сравнение обязано сначала привести оба числа к общему масштабу, а хэш обязан считаться не от байтов, а от канонической формы числа.

А это в свою очередь означает, что у операций над numeric есть принципиальное ветвление, зависящее от данных. А ветвление, зависящее от данных, — это то, что процессор и компилятор ненавидят больше всего. Однородный код вроде «сравнить сто чисел подряд» процессор умеет исполнять пачками, по несколько значений за такт (SIMD), а предсказатель переходов на нём не ошибается ни разу. Как только внутри появляется «если масштабы разные, то сначала выровнять» — пачками уже не получится, и каждая неверно предсказанная ветка стоит десятков тактов. Для bigint компилятор может развернуть сравнение в две-три инструкции; для numeric он вынужден оставить полноценную функцию с ветвлениями.

Кстати, про агрегаты. Промежуточное состояние sum(numeric) может не быть значением того же типа — сумма растёт с числом строк. Внутри для этого придумана отдельная структура NumericSumAccum. Комментарий к ней объясняет устройство лучше любого пересказа:

It uses 32-bit integers to store the digits, instead of the normal 16-bit integers (with NBASE=10000). This way, we can safely accumulate up to NBASE - 1 values without propagating carry, before risking overflow of any of the digits.

И вторая половина того же комментария:

Positive and negative values are accumulated separately, in ‘pos_digits’ and ‘neg_digits’.

То есть переносы разрядов делаются не на каждой строке, а раз в 9999 значений, и положительные с отрицательными копятся в двух отдельных буферах. Не зря sum(bigint) возвращает numeric — по той же самой причине.

Управление точностью промежуточных результатов в decimal

В DuckDB масштаб операции вычисляется и фиксируется на этапе планирования:

select typeof(1.8), typeof(1.9), typeof(1.8*1.9), 1.8*1.9;
-- DECIMAL(2,1)  DECIMAL(2,1)  DECIMAL(4,2)  3.42

Архитектурно это стало возможно, поскольку тип привязан к колонке результата, а не хранится в значении: в рантайме промежуточные значения едут пачкой, у которой один тип — масштаб хранится один раз, а не при каждом числе.

Так что точная формулировка различия такая. numeric — это самоописывающееся значение: оно несёт в себе всё, что нужно, чтобы его сравнить, сложить и напечатать, и потому может лежать в колонке, объявленной просто как numeric, без всякой точности. DECIMAL в DuckDB — это самоописывающийся тип, причём тип прикреплён не только к колонке, но и к каждому узлу дерева выражений; в значение он не спускается никогда. Поэтому там и не бывает DECIMAL без параметров — если их не указать, подставляется DECIMAL(18,3).

Как же у DuckDB получилось то, чего не получилось у PostgreSQL? Смело и инновационно мыслящий коллектив разработчиков?

Не совсем — DuckDB проблему определения масштаба не решил, а отменил.

Для сложения и умножения масштаб результата вычисляется из объявленных масштабов операндов и известен на этапе связывания: + даёт max(s1,s2), * даёт s1+s2. Ни одного обращения к данным, планировщик знает ширину результата точно. Цена — в том, что сохраняется масштаб, но не сохраняется точность: ширину результата прижимают к границе физического контейнера входов, лишь бы не переходить на дорогой ярус. Комментарий в исходниках DuckDB объясняет решение следующим образом:

we don’t automatically promote past the hugeint boundary to avoid the large hugeint performance penalty

Ещё раз, поскольку сразу так и не поверишь: «мы не расширяемся за границу hugeint, чтобы избежать большого штрафа за производительность hugeint».

У умножения вывод типа свой — и там стоит точно такое же ограничение на MAX_WIDTH_INT64, только без пояснения в комментарии. Если совсем простым языком и на примере, то масштаб результата получается следующий:

DECIMAL(18,2) * DECIMAL(18,2)  →  DECIMAL(18,4)   ← должно быть 36,4
DECIMAL(20,2) * DECIMAL(20,2)  →  DECIMAL(38,4)   ← вход уже int128, правило не сработало

Что это означает на практике? Давайте посмотрим.

SELECT cast(999999999999999999 AS decimal(18,0)) * cast(999999999999999999 AS decimal(18,0));
-- Out of Range Error: Overflow in multiplication of DECIMAL(18)

То есть формально корректный запрос падает в runtime error, потому что движок пожертвовал точностью и надёжностью в угоду скорости.

А для деления масштаб не выбирается вовсе — деление уходит в плавающую точку. Документация говорит прямо, в разделе «Arithmetic and Internal Representation»:

Division of fixed-point decimals does not typically produce numbers with finite decimal expansion. Therefore, DuckDB uses approximate floating-point arithmetic for all divisions that involve fixed-point decimals and accordingly returns floating-point data types.

Посмотрим, какие типы вычисляются для конкретных операций:

typeof(DECIMAL(10,2) / DECIMAL(10,2))  →  DOUBLE          1/3 = 0.3333333333333333
typeof(AVG(DECIMAL(10,2)))             →  DOUBLE
typeof(DECIMAL(10,2) % DECIMAL(10,2))  →  DECIMAL(10,2)   остаток остаётся точным

То есть AVG по денежной колонке в DuckDB — это double. Регламент ЕС 1103/97 с его «shall not be rounded or truncated» на таком типе не выполняется. Всю ту работу, которую выполняет numeric для аккуратного и предсказуемого определения масштаба деления, DuckDB просто не делает — и вместе с ней теряет точное десятичное деление, восстановить которое в этом дизайне уже нельзя.

Общий урок из этой пары решений пригодится любому, кто задумает фиксированную ширину: унификация масштаба и пределы точности операций — вещи, которые по отдельности выглядят разумно, а вместе приводят к потенциально большой проблеме.

Цена самой десятичности

Всё, о чём шла речь выше, — следствия четырёх решений: их можно было принять иначе. То, о чём пойдёт речь сейчас, иначе принять нельзя. Считать в десятичной системе на двоичном железе — значит постоянно умножать и делить на степени десятки, и от этой работы не избавляется никто. Её можно только перенести: либо в арифметику, либо в вывод.

numeric платит в арифметике и почти бесплатно печатает. DuckDB и MonetDB — наоборот: у них, как сказано в документации MonetDB, запятая «производится при отрисовке результата». Это один и тот же счёт, оплаченный в разных местах. Дальше — из чего он состоит и почему выбор места оплаты важнее, чем кажется.

Откуда берутся умножения на десятку. Чтобы сложить 1.5 и 1.50, надо сначала привести их к одному масштабу: у первого числа один знак после запятой, у второго два, поэтому 15 надо умножить на 10, получить 150, и только потом складывать. Сдвинуть значение на один знак — это умножить на 10, на два знака — на 100, на k знаков — на 10^k. k означает, на сколько десятичных знаков надо сдвинуть значение.

Округление — то же самое, только в другую сторону: round(x, 2) для числа с пятью знаками после запятой — это «поделить на 10³, округлить, умножить обратно», то есть k = 3.

И вот что тут главное: k никогда не написан в запросе. При выравнивании масштабов это разница dscale двух значений, при округлении — разница между dscale значения и запрошенной точностью. И то и другое становится известно только когда числа уже на руках.

Процессор не любит деление: это самая медленная из арифметических инструкций, десятки тактов. Поэтому компиляторы его избегают — если в коде написано x / 1000, компилятор заменяет деление умножением на «магическую» константу со сдвигом, и это уже несколько тактов. Но фокус работает только тогда, когда делитель написан прямо в коде: компилятор должен видеть конкретное число, чтобы посчитать для него ту самую константу. А в numeric в коде стоит не «поделить на 1000», а «поделить на десять в степени k» — значит, надо взять степень из таблицы и выполнить общее, медленное умножение. Или, если это деление, — настоящее деление.

Что numeric за это получает. Печать почти бесплатна: цифры уже лежат десятичными группами по четыре, numeric_out просто выписывает их в строку. У двоичного коэффициента так не выйдет — там печать это и есть цепочка делений на степень десятки, ровно та операция, которой мы только что боялись. И вот что важно в этом размене: вывод происходит на каждой возвращённой строке, а арифметика — только если в запросе есть арифметика. SELECT без вычислений печатает всё и не считает ничего.

Так что всякий раз, когда кто-то предлагает «просто хранить numeric как int128», стоит спросить, посчитал ли он побочные эффекты.

А если всё-таки хранить в 128-битном целом? Именно так выглядят все проекты «быстрого numeric». Их ждут две новости.

Первая — хорошая, но не настолько, насколько кажется. На обеих массовых архитектурах процессор работает с 64-битными числами, а 128-битные компилятор собирает из них. Умножение собирается дёшево — три обычных умножения и два сложения, всё вставляется прямо в код. А вот деления «128 на 128» в системе команд нет: на x86-64 самое широкое — divq, деление 128 на 64, и она аварийно завершается, если частное не влезло в 64 бита. Поэтому деление двух 128-битных чисел компилятор превращает в вызов библиотечной функции __udivti3.

Таким образом деление — не то, что чинится переходом на int128. Ускоряется не алгоритм деления, а всё, что вокруг него: palloc, распаковка укороченного заголовка, вычисление масштаба результата. Но ровно то же самое ускоряет и сложение — а значит, деление тут ни при чём.

Вторая новость плохая, и она про потолок. Чтобы поделить точно с масштабом s, надо посчитать a · 10^s / b — делимое сначала расширяется на s знаков. numeric в этом месте просто отращивает массив цифр. У int128 всего 38 цифр на всё, и расти некуда:

DECIMAL(18,2) / DECIMAL(18,2), результат с 6 знаками
    → делимое 18 + 6 = 24 цифры → int64 мало, нужен int128

DECIMAL(38,2) / DECIMAL(38,2), результат с 6 знаками
    → делимое 38 + 6 = 44 цифры → мало и int128

Условие работоспособности получается такое: точность аргументов плюс масштаб результата не больше 38. Всё, что не вмещается, требует либо int256 в промежуточном вычислении, либо ошибки в рантайме, либо double. DuckDB, будучи аналитической СУБД себе такое позволить может, а вот СУБД для выполнения ежедневных банковских транзакций врядли. Так что точное десятичное деление и жёсткий потолок плохо совмещаются в принципе.

DuckDB страдает меньше по той простой причине, что он касается степеней десятки реже. Умножение и деление на 10^k нужны только при выравнивании масштабов, а совпадают масштабы или нет — известно уже на этапе связывания, из объявленных типов. Если совпадают, цикл вырождается в обычное целочисленное сложение, и вся десятичность из горячего пути исчезает.

Дальше идёт приём, который стоит запомнить обязаательно тем, кто всё же решится сделать быстрый numeric. Проверка переполнения — это ветвление на каждый элемент, и она мешает векторизации. Оптимизатор DuckDB пытается по min/max-статистике колонки доказать, что переполнение здесь невозможно, и, если доказал, подменяет реализацию оператора на версию без проверки. После этого в цикле остаётся безусловное целочисленное сложение, которое компилятор автоматически векторизует. Вот это и делает точную десятичную арифметику дешёвой.

И финальная деталь, после которой цена десятичности выглядит совсем иначе. У DuckDB смена ширины стоит примерно столько же, сколько у PostgreSQL смена масштаба. DECIMAL(9,2) * DECIMAL(9,2) даёт DECIMAL(18,4), результат пересекает границу int32 → int64, и оба операнда приходится приводить: 40.0 мс против 13.9 мс у DECIMAL(18,2), у которого результат остаётся в int64. Узкий тип оказался втрое медленнее широкого.

По итогу можно сказать, что дешёвых десятичных типов не бывает — бывают типы, у которых дорогой случай встречается реже. numeric устроен так, что дорогой случай наступает почти всегда: масштабы приходят из данных, значит выравнивать надо постоянно. DuckDB устроен так, что дорогой случай наступает на границе контейнера — реже, но когда наступает, платится втрое.

Куда мы идём

Вернёмся к тому, с чего начали. Теперь для каждого из технических решений становится понятна цена, которую СУБД должна заплатить:

решение

чем платим

Представление не зависит от объявления

переменная длина: palloc на каждую операцию, распаковка укороченного заголовка, обрыв кэша смещений при разборе кортежа

Масштаб живёт в значении

сравнение и хэш обязаны выравнивать масштабы — ветвление по данным там, где у bigint одна инструкция

Масштаб результата выводится из данных

размер промежуточного результата неизвестен до рантайма, отсюда же и специализированная оптимизация NumericSumAccum у агрегатов

Потолок отодвинут за горизонт

место под результат отводится по факту, а не по объявлению

Таким образом, формат numeric платит за степени десятки в арифметике и почти бесплатно печатает. При этом предложение «давайте хранить numeric как int128» выглядит не очень перспективным, поскольку деление от этого существенно не подешевеет, печать подорожает, а точное деление упрётся в потолок, за которым его просто нет. При текущей парадигме точного десятичного типа оптимизации нужно искать скорее не в арифметике, а обвязке вокруг неё — palloc, распаковка, вычисление масштаба.

Итак, диагноз поставлен: numeric медленный не из-за десятичной арифметики, а из-за четырёх сознательных решений, за каждым из которых стоит здравый довод. Что с этим делать и можно ли сделать хоть что-то — вопрос дальнейших исследований.

THE END, 22 августа 2026 г., Мадрид, Испания.

Комментарии (0)