spark-test commented on code in PR #60196:
URL: https://github.com/apache/spark/pull/60196#discussion_r4500951214


##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -1037,18 +1095,20 @@ public boolean append(Object kbase, long koff, int 
klen, Object vbase, long voff
    * @return whether there is enough space to allocate the new page.
    */
   private boolean acquireNewPage(long required) {
+    final long size = Math.max(nextPageSizeBytes, required);
     final MemoryBlock page;
     try {
-      page = allocatePage(required);
+      page = allocatePage(size, required);
     } catch (SparkOutOfMemoryError e) {
       return false;
     }
     // Retaining exact-fit minimum-retry pages would consume one page-table 
slot per map entry.
-    if (required < pageSizeBytes && page.size() == required &&
+    if (required < size && page.size() == required &&
         isPageAllocationFromMinimumRetry(page)) {
       freePage(page);
       return false;
     }
+    nextPageSizeBytes = Math.min(pageSizeBytes, nextPageSizeBytes * 2);

Review Comment:
   **Non-blocking (P2):** With `INITIAL_PAGE_SIZE_BYTES` at 1 MiB, this 
doubling gives an `UnsafeHashedRelation` growth pages of 1, 2, 4, 8, 16 and 32 
MiB, which add up to 63 MiB, 1 MiB short of a 64 MiB page. Every later page is 
a full page, so each full-page boundary now sits about 1 MiB below where it was 
on the base branch, where all pages were the page size P.
   
   A relation whose data falls in that last MiB below a multiple of P now needs 
one more full page than before: about 63.5 MiB of rows reserves 127 MiB instead 
of 64 MiB, and 127.5 MiB reserves 191 MiB instead of 128 MiB (from a model of 
this code with 80-byte rows). That is 1 MiB / P of all relation sizes: about 
1.6% at 64 MiB, but 25% with a 4 MiB page and half with a 2 MiB page, both 
derived defaults on small executors. A configured page size that is not a 
power-of-two multiple of 1 MiB fares worse, because the growth pages overshoot 
one page: at 48 MiB about half of all sizes reserve up to 15 MiB more. Rows of 
a few MiB hit it for most sizes.
   
   These relations already reserved memory close to their size on the base 
branch, so for them the change makes things worse rather than better. In a 
shuffled hash join the extra full-page request can spill co-located consumers 
or take the task's remaining memory, and a memory-limited task holding several 
such relations can fail where it used to fit. For a broadcast it adds driver 
and executor heap and inflates the `dataSize` that the broadcast size limit 
checks. The excess is bounded per relation.
   
   **Recommended change:** Cap each growth page of a growing BytesToBytesMap at 
what is left to reach the page size, so the growth pages add up to exactly one 
page size before full pages begin. Keep a running total of the page sizes the 
map requested for the pages it kept; while that total is below the page size, 
request the smaller of nextPageSizeBytes and the remainder, and from then on 
the page size, still using a record's own size when it is larger. Reset the 
total in reset(). At the default this keeps the 1, 2, 4, 8, 16 and 32 MiB pages 
and adds a final 1 MiB page before the 64 MiB pages; with a 48 MiB page it 
gives 1, 2, 4, 8, 16 and 17 MiB. Update the docs that describe the growth (the 
nextPageSizeBytes field, the growing constructor, and 
UnsafeHashedRelation.INITIAL_PAGE_SIZE_BYTES) and the core page-sequence test, 
and add core map tests for page-size boundaries.
   
   **Why this works:** The doubling pages stop at exactly one page size, 
because the last growth page is only as large as what is left to reach it. From 
then on the total reserved is a whole multiple of the page size, the same grid 
as a layout of full pages from the start, instead of falling about 1 MiB short 
of it or overshooting it. Counting requested sizes rather than granted ones 
keeps maps created through the existing constructors, whose first request is 
already the page size, on full-page requests even after a partial grant.
   
   **Scope:** Cap the doubling pages of a growing BytesToBytesMap at what is 
left to reach the page size, update the docs that describe the growth, and 
update and add core tests for the page sequence and the page-size boundaries.
   
   **Compatibility:** Maps created through the existing constructors, including 
hash aggregation, keep requesting the page size for every page. Growing maps 
keep doubling from the 1 MiB initial page, so at power-of-two page sizes a 
relation smaller than the page size minus that page reserves what it does now, 
and deserialized relations rebuild their pages with the same rule.
   
   **Risks:** A relation larger than one page size reserves 1 MiB more than 
with the current doubling, the same as the base branch, because its growth 
pages now add up to a full page, and at the default a large relation uses seven 
growth pages instead of six. Restoring the uncapped doubling rolls the change 
back. When a record larger than the next page lands late in the growth phase, 
the last growth page can be very small, down to a single record, which costs 
one more page-table entry.
   
   **Constraints:** Maps created through the existing constructors must keep 
requesting the page size for every page, also after a partial grant, so hash 
aggregation and the other existing users stay unchanged. A record larger than 
the next page must still get a page of its own size, and the exact-fit 
minimum-retry rejection must keep applying whenever the request was padded 
beyond the record's need. reset() must restore both the next page size and the 
running total, so a reset map starts again from the initial page size. If a 
header adjustment is adopted for the heap-region question, apply it inside this 
rule, so the growth pages still end within a few header bytes of one page size 
instead of about 1 MiB short of it.
   
   **Exclusions:**
   - Rows of hundreds of KiB or more can still need one extra full page when 
the data falls just below a multiple of the page size, within a window of up to 
one row per growth page, because each growth page can leave up to one row of 
unused tail. Closing that would need moving records or sizing pages to the row 
size.
   - A partial grant during the growth phase leaves the growth pages short of 
one page size, because the total counts the requested size. This happens only 
under memory pressure, and counting granted bytes instead would change the 
requests of the existing constructors.
   
   **Success:** For a map created with an initial page size, when no record 
needs a page of its own and every request is granted in full, the data pages 
before the first full page add up to exactly the page size: the doubling pages, 
with the last one cut to what is left to reach the page size. An 
UnsafeHashedRelation of small rows never reserves more data-page memory than a 
layout of full pages from the start, except within a window of at most one row 
per growth page just below each multiple of the page size; for example, about 
63.5 MiB of 80-byte rows reserves 64 MiB, not 127 MiB. With a page size that is 
a power-of-two multiple of the initial size, a relation whose data stays below 
the page size minus the initial size reserves exactly what it does with the 
current doubling. Maps created through the existing constructors still request 
the page size for every page, also after a partial grant, so hash aggregation 
behaves as before. reset() restarts the page sequence from the initial p
 age size.
   
   **Verification:**
   - **Regression:** A map whose initial page size is a small fraction of its 
page size, filled with small records until its data lies within the last 
initial page below one page size but more than one record per growth page below 
it, and then likewise below two page sizes, reserves exactly one and then two 
page sizes of data pages, not a page size plus the growth pages.
   - **Regression:** When the page size is not a power-of-two multiple of the 
initial size, for example a 6 KiB page with a 1 KiB initial page, the growth 
pages are 1, 2 and 3 KiB and end exactly at the page size before full pages 
begin, and a map with about 5 KiB of data reserves 6 KiB, not 7 KiB.
   - **Behavior:** The page-sequence test checks the first page, the following 
doubling pages, the last growth page cut to what is left to reach the page 
size, an exact-size page for a record larger than the next page, and the 
initial page size again after reset().
   - **Compatibility:** Maps created through the existing constructors still 
request the page size for every page, including after a partial first page, and 
the existing BytesToBytesMap, TaskMemoryManager and hash aggregation tests pass 
unchanged.
   - **Behavior:** Small UnsafeHashedRelations still reserve less than two 
initial pages, both when built and after Java and Kryo deserialization.



##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -259,10 +290,37 @@ public BytesToBytesMap(
       throw new IllegalArgumentException("Page size " + pageSizeBytes + " 
cannot exceed " +
         TaskMemoryManager.MAXIMUM_PAGE_SIZE_BYTES);
     }
+    if (initialPageSizeBytes <= 0) {
+      throw new IllegalArgumentException("Initial page size must be greater 
than 0");
+    }
+    this.initialPageSizeBytes = Math.min(initialPageSizeBytes, pageSizeBytes);

Review Comment:
   **Nit (P3):** `UnsafeHashedRelation` relies on this cap whenever the 
`SparkEnv` page size is below 1 MiB, from an explicit `spark.buffer.pageSize` 
or the 1 MiB - 16 bytes derived default under G1 on-heap. No test exercises it: 
`dataPagesGrowFromInitialPageSize` uses a 1 KiB initial page with a 4 KiB page 
size, and the existing constructors pass equal values. If the cap regressed, 
such a relation would reserve up to 1 MiB, more than the single page it used to 
reserve, and no test would fail. The existing `appendUntilNewDataPage` helper 
can check the first page of such a map.
   
   **Verification:**
   - **Regression:** A map created with an initial page size larger than its 
page size allocates its first data page at the page size, and the check fails 
if that first page can exceed the page size.



##########
core/src/main/java/org/apache/spark/unsafe/map/BytesToBytesMap.java:
##########
@@ -175,6 +175,17 @@ private enum MapState {
    */
   private final long pageSizeBytes;
 
+  /**
+   * The size of the first data page, at most {@link #pageSizeBytes}.

Review Comment:
   **Nit (P3):** This doc and the `nextPageSizeBytes` doc below it link to 
`pageSizeBytes` as the cap of a growing map's pages, but the unchanged 
`pageSizeBytes` doc just above still says it is "The size of the data pages 
that hold key and value data". That is no longer true for any 
`UnsafeHashedRelation` map, so the field docs now contradict each other and 
invite reading `pageSizeBytes` as the size of every page. That doc could 
instead call it the maximum size of regular data pages, which a map with a 
smaller initial page grows up to; its claim that this limits the maximum entry 
size was already inaccurate, since a larger record gets a page of its own size.
   
   **Verification:**
   - **Inspection:** The field doc names pageSizeBytes as the maximum size of 
regular data pages, and no longer says it is the size of every data page or the 
maximum entry size.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/HashedRelation.scala:
##########
@@ -452,6 +453,17 @@ private[joins] class UnsafeHashedRelation(
 
 private[joins] object UnsafeHashedRelation {
 
+  /**
+   * The size of the first data page of the map. Later pages double in size up 
to the page size.
+   *
+   * Otherwise `BytesToBytesMap` rounds its first page up to the page size 
(`spark.buffer.pageSize`,
+   * up to 64 MiB), so even a relation with a few rows reserves 64 MiB. A task 
that keeps many

Review Comment:
   **Nit (P3):** Only the derived default page size is capped at 64 MiB. An 
explicit `spark.buffer.pageSize` has no such bound 
(`MemoryManager.pageSizeBytes` uses it as is), and a small derived default can 
be as low as 1 MiB, so a few-row relation used to reserve one full page of 
whatever size applied, not always 64 MiB. The sibling `LongToUnsafeRowMap` 
comment qualifies the same claim with "With the default page size". For example:
   
   ```suggestion
      * Otherwise `BytesToBytesMap` rounds its first page up to the page size 
(`spark.buffer.pageSize`
      * or a default of up to 64 MiB), so even a few rows reserve a full page. 
A task that keeps many
   ```
   
   **Verification:**
   - **Inspection:** The rationale describes the page size as the configured 
spark.buffer.pageSize or a derived default of at most 64 MiB, and a few-row 
relation's old reservation as one full page.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/joins/HashedRelation.scala:
##########
@@ -469,7 +481,8 @@ private[joins] object UnsafeHashedRelation {
       taskMemoryManager,
       // Only 70% of the slots can be used before growing, more capacity help 
to reduce collision
       (sizeEstimate * 1.5 + 1).toInt,
-      pageSizeBytes)
+      pageSizeBytes,

Review Comment:
   **Nit (P3):** Reordering the constructor's parameters, as discussed in 
review, makes a swap less likely, but nothing would detect one here or in 
`read`. With the two longs swapped, the map's page size would become 1 MiB and 
its pages would never grow past it, so a large relation would need up to 64x 
more pages and could exhaust the 8192-entry page table. The new tests would 
still pass, because their relations stay within the first 1 MiB page and the 
map, whose page size would show the wiring, is private to 
`UnsafeHashedRelation`. A small test-visible accessor for the map's 
`getPageSizeBytes()` would let the tests check that a built relation and its 
Java and Kryo copies use the `SparkEnv` page size.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/60196#discussion_r4500475752).
   
   **Verification:**
   - **Regression:** A built UnsafeHashedRelation and its Java and Kryo 
deserialized copies report the SparkEnv page size as their map's page size, and 
the check fails if either call site swaps the two arguments.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to