Skip to content

[opt](paimon) Reduce JNI read object allocations - #66244

Open
Gabriel39 wants to merge 1 commit into
apache:masterfrom
Gabriel39:ai/doris-26925-paimon-jni-allocation
Open

[opt](paimon) Reduce JNI read object allocations#66244
Gabriel39 wants to merge 1 commit into
apache:masterfrom
Gabriel39:ai/doris-26925-paimon-jni-allocation

Conversation

@Gabriel39

@Gabriel39 Gabriel39 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: N/A

Related PR: #66223

Problem Summary:

The Paimon JNI read path created a new PaimonColumnValue for every array element, map key/value, and struct field on every row. Timestamp conversions also resolved the session time zone per value and built redundant intermediate temporal objects. On wide nested data or large scan batches, these short-lived objects increase allocation rate and GC pressure.

For example, consider two consecutive ARRAY<INT> rows, [10, 20] and [30, 40]. Previously, reading the second row allocated two new wrappers even though the first row's wrappers had already been consumed. This PR keeps two position-specific wrappers and resets their record, index, Doris type, Paimon type, and time zone before reading the second row. This is safe because VectorColumn consumes the unpacked list synchronously before the scanner advances to another value.

The same invariant is applied separately to array elements, map keys, map values, and struct fields. Caches are lazy, grow only when needed, and only the current collection size or projected struct indexes are emitted, so shrinking collections cannot expose stale entries. Struct wrappers are cached by the original field index, preserving sparse/non-contiguous projection behavior fixed by #66223.

For temporal values, the session time-zone string is now parsed once into a ZoneId. A Paimon TIMESTAMP_LTZ epoch instant is converted directly into that zone instead of constructing UTC and target-zone intermediates. TIMESTAMPTZ uses Paimon's equivalent local representation directly instead of converting through Instant and UTC.

What changed?

  • Lazily reuse nested PaimonColumnValue wrappers across rows for arrays, maps, structs, and recursively nested values.
  • Reset all row-dependent state before a cached wrapper is reused.
  • Cache the parsed session ZoneId instead of resolving it for every value.
  • Remove redundant temporal conversion objects while preserving negative-epoch, nanosecond, and DST behavior.
  • Add unit coverage for wrapper identity reuse, growing/shrinking arrays, maps, sparse struct projections, nested arrays, DST, negative epoch, and nanosecond precision.

Local allocation and latency microbenchmark

Environment: Linux x86_64, Oracle JDK 17.0.16, Paimon 1.3.1. The benchmark ran latest master and this commit in separate worktrees on the same machine. Each case used 100,000 warmup iterations and 5 measured trials; the table reports medians. Collection cases ran 300,000 operations per trial, timestamp cases ran 1,000,000. Allocation was measured with ThreadMXBean; GC count/time was measured with GarbageCollectorMXBean. A full GC was requested before each measured trial and excluded from the reported GC delta.

The collection output lists were preallocated and cleared between operations to isolate allocation inside the Paimon value conversion path. Therefore these numbers are a focused Java microbenchmark, not an end-to-end query throughput claim.

Benchmark data shape and values:

  • ARRAY<INT>[16]: one GenericRow containing one GenericArray with [0, 1, ..., 15].
  • MAP<INT,BIGINT>[16]: one GenericRow containing one insertion-ordered GenericMap with {0: 0, 1: 1, ..., 15: 15}.
  • STRUCT<INT,STRING,BIGINT>: one binary row containing (10, "x", 100); all three fields are projected.
  • TIMESTAMP_LTZ(9): one fixed instant, 2024-03-10T10:30:00.123456789Z, read in session zone America/Los_Angeles. This date exercises the US DST transition day.
  • TIMESTAMPTZ(9): one fixed pre-epoch value, 1969-12-31T23:59:59.999999999Z, read in UTC.

Every benchmark operation rereads the same Paimon row/value. This intentionally removes file I/O, row construction, and table scan variability, and isolates the allocation/latency of PaimonColumnValue.unpack*() or timestamp conversion. The core collection loop is equivalent to:

values.clear();                 // preallocated outside the measured loop
columnValue.unpackArray(values);
consume(values.get(0), values.get(15));

On master, each unpackArray call above creates 16 wrapper objects. With this PR, the warmup creates the 16 position-specific wrappers once and measured iterations only reset them. Map and struct use the same loop shape with separate key/value or projected-field output lists.

Case master alloc (B/op) PR alloc (B/op) Allocation reduction master time (ns/op) PR time (ns/op) Time reduction Median GC collections/trial
ARRAY<INT>[16] 512.03 0.03 99.99% 368.38 141.61 61.6% 2 → 0
MAP<INT,BIGINT>[16] 1232.03 208.03 83.1% 668.39 508.57 23.9% 2 → 1
STRUCT<INT,STRING,BIGINT> 128.03 32.03 75.0% 63.74 63.73 ~0% 0 → 0
TIMESTAMP_LTZ(9) 384.01 64.01 83.3% 156.25 57.88 63.0% 2 → 1
TIMESTAMPTZ(9) 200.01 48.01 76.0% 44.45 21.65 51.3% 1 → 0

The remaining map allocation comes primarily from Paimon's GenericMap.keyArray() / valueArray() materialization, which is unchanged by this PR. Struct latency is effectively unchanged while allocation drops by 75%.

Release note

None

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
    • Manual test (the local allocation/latency microbenchmark above)
    • No need to test or manual test.
  • Behavior changed:

    • No.
    • Yes.
  • Does this need documentation?

    • No.
    • Yes.

Validation command:

mvn -Dmaven.build.cache.enabled=false -pl be-java-extensions/paimon-scanner -am -Dtest='org.apache.doris.paimon.*Test' -Dsurefire.failIfNoSpecifiedTests=false test

Result: 20 tests, 0 failures, 0 errors, 0 skipped.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@Gabriel39

Copy link
Copy Markdown
Contributor Author

run buildall

@Gabriel39

Copy link
Copy Markdown
Contributor Author

/review

@Gabriel39
Gabriel39 marked this pull request as ready for review July 29, 2026 14:10
@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 29357 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 48a06b0fe318bcdb033d22ec0db2d48ccd1b377f, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17643	4068	4039	4039
q2	2038	323	197	197
q3	10273	1413	832	832
q4	4691	466	333	333
q5	7516	846	562	562
q6	202	182	136	136
q7	774	830	612	612
q8	10164	1626	1611	1611
q9	6054	4283	4307	4283
q10	6801	1724	1471	1471
q11	498	342	318	318
q12	787	578	472	472
q13	18099	3284	2706	2706
q14	267	262	253	253
q15	q16	779	778	704	704
q17	981	944	943	943
q18	6751	5743	5516	5516
q19	1599	1282	1084	1084
q20	795	691	578	578
q21	5633	2701	2413	2413
q22	423	347	294	294
Total cold run time: 102768 ms
Total hot run time: 29357 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4452	4416	4328	4328
q2	297	322	215	215
q3	4567	4900	4350	4350
q4	2079	2121	1373	1373
q5	4377	4269	4217	4217
q6	231	181	131	131
q7	1709	1944	1779	1779
q8	2427	2145	2074	2074
q9	7702	7656	7596	7596
q10	4733	4665	4168	4168
q11	597	413	385	385
q12	753	746	549	549
q13	3396	3551	3020	3020
q14	305	330	297	297
q15	q16	747	760	678	678
q17	1380	1349	1359	1349
q18	7900	7272	6819	6819
q19	1111	1093	1096	1093
q20	2211	2179	1933	1933
q21	5232	4489	4413	4413
q22	534	456	412	412
Total cold run time: 56740 ms
Total hot run time: 51179 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 177045 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 48a06b0fe318bcdb033d22ec0db2d48ccd1b377f, data reload: false

query5	4329	610	471	471
query6	463	221	205	205
query7	4964	620	341	341
query8	337	190	171	171
query9	8786	4091	4050	4050
query10	471	359	290	290
query11	5935	2294	2079	2079
query12	158	103	99	99
query13	1268	595	450	450
query14	6244	5250	4900	4900
query14_1	4302	4209	4208	4208
query15	203	206	175	175
query16	992	477	437	437
query17	936	690	555	555
query18	2428	450	347	347
query19	209	184	142	142
query20	111	107	107	107
query21	235	164	136	136
query22	13553	13597	13299	13299
query23	17268	16313	16122	16122
query23_1	16077	16112	16129	16112
query24	7525	1783	1283	1283
query24_1	1288	1300	1288	1288
query25	536	456	394	394
query26	1383	389	223	223
query27	2559	594	398	398
query28	4453	2023	2002	2002
query29	1060	641	504	504
query30	354	258	227	227
query31	1115	1092	972	972
query32	112	64	62	62
query33	552	323	256	256
query34	1178	1145	670	670
query35	764	790	668	668
query36	1023	1038	870	870
query37	160	108	90	90
query38	1874	1697	1675	1675
query39	865	855	839	839
query39_1	835	841	830	830
query40	246	169	149	149
query41	71	71	69	69
query42	97	95	93	93
query43	325	326	288	288
query44	1441	798	777	777
query45	198	184	170	170
query46	1070	1234	705	705
query47	2070	2050	1974	1974
query48	375	415	303	303
query49	598	424	324	324
query50	1055	443	350	350
query51	10592	10454	10386	10386
query52	87	92	76	76
query53	267	300	204	204
query54	290	247	241	241
query55	77	73	72	72
query56	309	317	300	300
query57	1325	1252	1166	1166
query58	307	268	243	243
query59	1575	1632	1427	1427
query60	320	281	269	269
query61	180	171	171	171
query62	538	506	437	437
query63	250	211	205	205
query64	2849	1031	817	817
query65	4734	4608	4635	4608
query66	1857	507	378	378
query67	29224	29136	29015	29015
query68	3172	1531	976	976
query69	436	293	273	273
query70	923	814	826	814
query71	371	357	322	322
query72	3006	2682	2372	2372
query73	818	799	422	422
query74	5058	4927	4688	4688
query75	2519	2481	2119	2119
query76	2311	1161	791	791
query77	351	377	281	281
query78	11748	11882	11273	11273
query79	1376	1143	751	751
query80	1291	557	472	472
query81	526	330	284	284
query82	618	158	121	121
query83	371	329	295	295
query84	328	161	127	127
query85	962	616	542	542
query86	415	236	236	236
query87	1865	1805	1757	1757
query88	3757	2818	2790	2790
query89	447	379	339	339
query90	2022	196	207	196
query91	194	191	159	159
query92	65	60	56	56
query93	1717	1477	1028	1028
query94	706	340	309	309
query95	774	583	461	461
query96	1069	759	342	342
query97	2650	2596	2480	2480
query98	208	212	198	198
query99	1097	1124	973	973
Total cold run time: 262716 ms
Total hot run time: 177045 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 25 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 48a06b0fe318bcdb033d22ec0db2d48ccd1b377f, data reload: false

query1	0.01	0.00	0.01
query2	0.10	0.04	0.04
query3	0.25	0.13	0.14
query4	1.62	0.13	0.14
query5	0.27	0.22	0.24
query6	1.27	1.06	1.09
query7	0.04	0.00	0.00
query8	0.06	0.03	0.04
query9	0.40	0.32	0.33
query10	0.60	0.60	0.59
query11	0.20	0.13	0.14
query12	0.18	0.14	0.14
query13	0.47	0.48	0.48
query14	1.03	0.99	1.00
query15	0.60	0.59	0.60
query16	0.32	0.34	0.32
query17	1.07	1.12	1.19
query18	0.23	0.21	0.22
query19	2.14	1.97	1.96
query20	0.02	0.01	0.02
query21	15.43	0.21	0.13
query22	4.84	0.05	0.05
query23	16.14	0.31	0.12
query24	2.94	0.42	0.32
query25	0.13	0.05	0.05
query26	0.74	0.20	0.14
query27	0.04	0.03	0.03
query28	3.50	0.98	0.54
query29	12.48	4.18	3.27
query30	0.28	0.16	0.16
query31	2.76	0.61	0.32
query32	3.21	0.59	0.50
query33	3.21	3.13	3.24
query34	15.66	4.22	3.51
query35	3.48	3.53	3.50
query36	0.57	0.41	0.44
query37	0.08	0.07	0.06
query38	0.05	0.04	0.03
query39	0.04	0.03	0.03
query40	0.17	0.16	0.15
query41	0.09	0.03	0.03
query42	0.03	0.02	0.02
query43	0.04	0.04	0.03
Total cold run time: 96.79 s
Total hot run time: 25 s

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes.

Two blocking issues remain:

  • The eagerly cached ZoneId rejects Doris-supported short session-zone aliases before any value is read, including scalar-only Paimon scans.
  • Recursive per-position caches retain the historical union of nested shapes for the scanner lifetime, so valid shifting nested data can require multiplicatively more heap than any individual row.

Critical checkpoint conclusions:

  • Goal and proof: Reusing wrappers and caching timestamp conversion state reduces allocations for stable shapes, and the new unit assertions cover identity reuse and selected temporal boundaries. The two findings above show the optimization is not yet safe over the full production input/session domain.
  • Scope and clarity: The PR is focused on two Paimon scanner files and uses one shared reuse helper. No unrelated changes were found.
  • Concurrency: Each Java scanner is driven serially by its owning JNI reader, and VectorColumn recursively consumes unpacked values synchronously before rebinding. No race, lock, or deadlock issue was found.
  • Lifecycle and memory: Active wrappers are rebound correctly, but inactive recursive caches survive shrink/null transitions and releaseBatch, producing the reported scanner-lifetime historical-shape retention.
  • Compatibility and error behavior: No persisted, storage-format, RPC, or JNI layout change is involved, so no rolling-upgrade shim is needed. The eager one-argument zone parse is a behavior compatibility regression for valid Doris sessions; JNI error propagation otherwise remains intact.
  • Parallel and conditional paths: Arrays, maps, structs, sparse struct indexes, nesting, nulls, grow/shrink emission, mixed top-level schemas, both Paimon timestamp mappings, and both JNI reader generations were traced. The timestamp fast paths are algebraically equivalent to the previous UTC conversions; no separate timestamp-value bug was found.
  • Tests and results: The added expected values are correct, but coverage misses a Doris short-zone scanner construction and shifting nested high-water marks/retained heap. No builds or tests were run in this review-only environment, as required by the review instructions.
  • Performance and observability: Stable-shape allocation reductions are credible, but the benchmark's fixed 16-element shape does not bound historical recursive retention. Existing scanner/JVM metrics add no distinct blocker beyond fixing that retention.
  • Configuration, transactions, persistence, data writes, FE-BE variable additions, static initialization, and master failover are not applicable.

User focus: no additional focus was provided; the complete PR was reviewed.


public void setTimeZone(String timeZone) {
this.timeZone = timeZone;
this.timeZone = ZoneId.of(timeZone);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doris accepts short session-zone IDs through TimeUtils.timeZoneAliasMap (for example, EST) and forwards them unchanged to JniReader; plain ZoneId.of("EST") throws ZoneRulesException. Because PaimonJniScanner calls this setter unconditionally, this change makes even an INT-only Paimon scan fail during scanner construction, whereas the previous setter did not resolve the zone unless an LTZ value was read. Please resolve with Doris-equivalent alias semantics (including its CST handling) and cover a supported short ID on a non-temporal scan.

List<PaimonColumnValue> cache, int cacheIndex, DataGetters childRecord, int childIndex,
ColumnType childDorisType, DataType childPaimonType) {
while (cache.size() <= cacheIndex) {
cache.add(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These recursive per-position caches keep the historical union of nested shapes until the scanner is closed. For ARRAY<ARRAY<INT>> with outer size N, successive rows can put an M-element inner array at a different outer position while the others are empty; every row has O(N+M) elements, but each outer wrapper permanently keeps its own M-entry child cache, leaving O(N*M) wrappers (and their last Paimon container references) reachable from the scanner. Shrink/null paths never prune them, and the fixed-size benchmark does not exercise this. Please bound/prune nested caches or use a scanner-owned scratch/pool design whose retained size follows the maximum live row/batch shape, and add a shifting-nested-shape retention test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants