Три независимые попытки сделать для PostgreSQL быстрый точный десятичный тип расширением заглохли. Но не потому, что не получилось ускорить арифметику, — арифметику как раз каждое из них ускоряло. Тогда в чём же дело? Здесь я разбираю технические решения в устройстве numeric, которые приводят к высокой стоимости использования этого типа данных. Изучение провожу в сравнении с устройством типа decimal в DuckDB - будучи OLAP СУБД, он свободен от некоторых ограничений PostgreSQL, и гонясь за производительностью, выбирал другой путь развития. Копнуть матчасть
Тип 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 пошёл другим путём — сознательно приняв следующие ключевые решения:
Представление не зависит от объявления. Все прочие выбирают ширину значения по объявленной точности: узкое число — два-четыре байта, широкое — восемь или шестнадцать. В PostgreSQL ширина — свойство типа, а не колонки, поэтому numeric обязан быть переменной длины.
Масштаб живёт в значении, а не в описании колонки. Поэтому 1.5 и 1.50 различаются даже в numeric колонке, для которой не специфицируется масштаб.
Масштаб результата вычисляется из данных, а не заранее. Сколько знаков даст деление, зависит от самих чисел, а не только от их типов.
Гибкая верхняя граница точности. Формально предел у numeric есть — 131072 знака до запятой и 16383 после, — но ни одно практическое значение к нему не приближается: место под результат отводится по тому, что получилось, а не по объявлению и получить ошибку переполнения в рантайме практически невозможно.
По отдельности каждое решение выглядит разумно, и почти каждое продиктовано требованиями к точности вычислений, характерных для различных индустрий. Но у каждого есть цена, и дальше — про то, из чего эта цена складывается. Каждый следующий раздел разбирает один пункт этого списка: представление — первый, передача по значению и разбор кортежа — его последствия, масштаб промежуточного результата — второй и третий, цена десятичности — то, что остаётся, даже если отказаться от всех четырёх.
Сравнивать я буду с популярной СУБД DuckDB в основном для контраста. Потому, что он принял ровно противоположные решения по всем четырём пунктам, и на контрасте видно, где именно Postgres теряет производительность. Ну и да, его код можно свободно читать и анализировать.
Краткий словарь PostgreSQL InternalsТермин | Значение |
|---|---|
varlena | значение переменной длины: сначала заголовок с длиной, потом данные |
детоаст | распаковка значения перед тем, как с ним работать |
| машинное слово, в котором значение путешествует по исполнителю запроса |
typmod | объявленные в схеме точность и масштаб, вроде |
dscale | сколько знаков после запятой печатать — хранится в самом значении |
deform | разбор строки таблицы на отдельные колонки |
Процессоры общего назначения считают в двоичной системе, а 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.
Комментарий из исходников PostgreSQLThe 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 — по той же самой причине.
В 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 устроен так, что дорогой случай наступает на границе контейнера — реже, но когда наступает, платится втрое.
Вернёмся к тому, с чего начали. Теперь для каждого из технических решений становится понятна цена, которую СУБД должна заплатить:
решение | чем платим |
|---|---|
Представление не зависит от объявления | переменная длина: |
Масштаб живёт в значении | сравнение и хэш обязаны выравнивать масштабы — ветвление по данным там, где у |
Масштаб результата выводится из данных | размер промежуточного результата неизвестен до рантайма, отсюда же и специализированная оптимизация |
Потолок отодвинут за горизонт | место под результат отводится по факту, а не по объявлению |
Таким образом, формат numeric платит за степени десятки в арифметике и почти бесплатно печатает. При этом предложение «давайте хранить numeric как int128» выглядит не очень перспективным, поскольку деление от этого существенно не подешевеет, печать подорожает, а точное деление упрётся в потолок, за которым его просто нет. При текущей парадигме точного десятичного типа оптимизации нужно искать скорее не в арифметике, а обвязке вокруг неё — palloc, распаковка, вычисление масштаба.
Итак, диагноз поставлен: numeric медленный не из-за десятичной арифметики, а из-за четырёх сознательных решений, за каждым из которых стоит здравый довод. Что с этим делать и можно ли сделать хоть что-то — вопрос дальнейших исследований.
THE END, 22 августа 2026 г., Мадрид, Испания.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Почему в БД на PostgreSQL популярен тип numeric? | -1 | 8.72 | 13-08-2026 |
| 2 | [Перевод] Статистика PostgreSQL: почему запросы выполняются медленно | 0 | 7.74 | 19-08-2026 |
| 3 | Ловушка неявного приведения числовых типов | 0 | 5 | 30-06-2026 |
| 4 | Асинхронный I/O в PostgreSQL или история выходного дня | 0 | 9.78 | 17-08-2026 |
| 5 | Запросы с ANY: когда PostgreSQL дольше планирует, чем выполняет | 0 | 8.8 | 10-08-2026 |
| 6 | [Перевод] Нетипичные оптимизации в PostgreSQL, или Креативное ускорение запросов | 0 | 8.21 | 02-03-2026 |
| 7 | Диапазонный тип данных в PostgreSQL: ускоряем запросы | 5 | 7 | 26-06-2026 |
| 8 | 3000 точек на карте грузились полсекунды. Ускорял не там, где думал | 0 | 10.75 | 21-08-2026 |
| 9 | Четыре антипаттерна CTE в PostgreSQL: разбираем на EXPLAIN ANALYZE | 0 | 9.16 | 20-08-2026 |
| 10 | REPACK в PostgreSQL 19: перепаковка в ядре и, как всегда, дьявол в деталях | 0 | 8.98 | 20-08-2026 |