[ 
https://issues.apache.org/jira/browse/HDDS-16183?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Chi-Hsuan Huang updated HDDS-16183:
-----------------------------------
    Description: 
h3. Problem
{{OMKeyRequest.sumBlockLengths}} \({{OMKeyRequest.java:887\-897}}\) totals the 
length of every block a key points to, but reaches those blocks through 
{{OmKeyLocationInfoGroup.getLocationList\(\)}}, which builds a flattened copy 
on every call:

{code}
public static long sumBlockLengths\(OmKeyInfo omKeyInfo\) {
  long bytesUsed = 0;
  for \(OmKeyLocationInfoGroup group: omKeyInfo.getKeyLocationVersions\(\)\) {
    for \(OmKeyLocationInfo locationInfo : group.getLocationList\(\)\) {
      bytesUsed \+= QuotaUtil.getReplicatedSize\(
          locationInfo.getLength\(\), omKeyInfo.getReplicationConfig\(\)\);
    }
  }
  return bytesUsed;
}
{code}

{{getLocationList\(\)}} carries an explicit warning from its author 
\({{OmKeyLocationInfoGroup.java:103\-112}}\):

{quote}
Use this expensive method only when absolutely needed\! It creates a new list 
so it is not an O\(1\) operation. Use getLocationLists\(\) instead.
{quote}

It is implemented as 
{{locationVersionMap.values\(\).stream\(\).flatMap\(List::stream\).collect\(...\)}},
 so each call allocates a stream pipeline and a new {{ArrayList}} per version 
group. Summing lengths does not need a flattened list; {{getLocationLists\(\)}} 
returns {{locationVersionMap.values\(\)}} directly and an extra nested loop 
would visit exactly the same block objects.

h3. Where it is called
{{sumBlockLengths}} is used to compute released quota in 
{{OMKeyDeleteRequest.java:165}}, {{OMKeyDeleteRequestWithFSO.java:162}}, 
{{OMKeysDeleteRequest.java:322}}, {{OmKeysDeleteRequestWithFSO.java:115,156}} 
and {{OMKeyCommitRequest.java:358}}, which are all per\-request.

Two callers are not per\-request, and they are where this matters:

{{KeyManagerImpl.java:868}}, inside the loop that builds the reclaimable key 
list for {{KeyDeletingService}}. That one runs per pending\-delete key on every 
service iteration, so the allocation is repeated across the whole scan.

{{OMDirectoriesPurgeRequestWithFSO.java:191}}, inside the loop over 
{{path.getDeletedSubFilesList\(\)}}, itself nested in a loop over purge paths. 
That request is produced in batches by {{DirectoryDeletingService}}, so one 
request can carry many sub\-files. An earlier revision of this description 
listed it as per\-request, which was wrong.

h3. Measurement

An isolated measurement of {{sumBlockLengths}} alone was taken with a throwaway 
JUnit harness \(not committed; no new dependency\), counting per\-call thread 
allocation with {{ThreadMXBean.getThreadAllocatedBytes}} after warmup. Two runs 
agreed closely. Blocks are 256 MB, replication RATIS/THREE.

||key shape||before||after||saved||
|1 group x 1 block|129 ns / 469 B|14 ns / 0 B|469 B|
|1 group x 4 blocks|115 ns / 448 B|14 ns / 0 B|448 B|
|1 group x 32 blocks|252 ns / 784 B|54 ns / 0 B|784 B|
|4 groups x 4 blocks|339 ns / 1792 B|64 ns / 0 B|1792 B|
|16 groups x 8 blocks|1579 ns / 7168 B|366 ns / 0 B|7168 B|

The nested\-loop form allocates nothing: {{getLocationLists\(\)}} returns the 
live collection and the iterators are scalarized by escape analysis. The 
current form pays roughly 470 B and 130 ns even for a single\-block key, which 
is the fixed cost of the stream pipeline.

With {{ozone.key.deleting.limit.per.task}} at its default of 50000, and a 
typical key of one version group with four blocks, that is on the order of 22 
MB of garbage and 6 ms per {{KeyDeletingService}} scan.

h3. What this does and does not show

The allocation column is a direct measurement and is the solid part. The timing 
column is indicative only: this is a plain harness, not JMH, so it has no 
dead\-code or constant\-folding guards.

More importantly, this measures the method in isolation, not the 
{{KeyDeletingService}} scan end to end. It does not establish what share of 
that scan the change is worth, and 6 ms against 50000 RocksDB reads and 
{{OmKeyInfo}} deserializations is very likely still noise. The original caveat 
therefore stands, and no speedup is being claimed: the case for the change is 
that it follows the accessor's documented contract and removes provably 
unnecessary allocation, not that it makes deletion faster.

h3. Original verification note
This is an allocation observation, not a measurement. Nothing here has been 
benchmarked, and the effect may be too small to observe next to the RocksDB 
reads and the {{OmKeyInfo}} deserialization that dominate the same loop. 
Profiling or a benchmark of the {{KeyDeletingService}} scan should come before 
or with the change, so that the issue is closed on evidence rather than on the 
shape of the code.

h3. Notes
Behavior is unchanged either way: both accessors expose the same block objects, 
so the sum is identical, and the existing delete and purge tests cover it.

HDDS\-16195 covers a separate defect at the {{KeyManagerImpl}} caller: that 
loop walks the same blocks twice and recomputes the replicated size, 
independently of which accessor {{sumBlockLengths}} uses. The two changes do 
not conflict and can land in either order.

Noticed while reviewing HDDS\-16127, which added a second caller of 
{{sumBlockLengths}} in the quota repair recount. That caller only runs for keys 
retaining more than one version, so it is not the motivation here.

Pinned source commit 4766aa8609. Analysis assisted by AI tooling \(Claude Code, 
Opus 5\).


  was:
h3. Problem
{{OMKeyRequest.sumBlockLengths}} \({{OMKeyRequest.java:887\-897}}\) totals the 
length of every block a key points to, but reaches those blocks through 
{{OmKeyLocationInfoGroup.getLocationList\(\)}}, which builds a flattened copy 
on every call:

{code}
public static long sumBlockLengths\(OmKeyInfo omKeyInfo\) {
  long bytesUsed = 0;
  for \(OmKeyLocationInfoGroup group: omKeyInfo.getKeyLocationVersions\(\)\) {
    for \(OmKeyLocationInfo locationInfo : group.getLocationList\(\)\) {
      bytesUsed \+= QuotaUtil.getReplicatedSize\(
          locationInfo.getLength\(\), omKeyInfo.getReplicationConfig\(\)\);
    }
  }
  return bytesUsed;
}
{code}

{{getLocationList\(\)}} carries an explicit warning from its author 
\({{OmKeyLocationInfoGroup.java:103\-112}}\):

{quote}
Use this expensive method only when absolutely needed\! It creates a new list 
so it is not an O\(1\) operation. Use getLocationLists\(\) instead.
{quote}

It is implemented as 
{{locationVersionMap.values\(\).stream\(\).flatMap\(List::stream\).collect\(...\)}},
 so each call allocates a stream pipeline and a new {{ArrayList}} per version 
group. Summing lengths does not need a flattened list; {{getLocationLists\(\)}} 
returns {{locationVersionMap.values\(\)}} directly and an extra nested loop 
would visit exactly the same block objects.

h3. Where it is called
{{sumBlockLengths}} is used to compute released quota in 
{{OMKeyDeleteRequest.java:165}}, {{OMKeyDeleteRequestWithFSO.java:162}}, 
{{OMKeysDeleteRequest.java:322}}, {{OmKeysDeleteRequestWithFSO.java:115,156}} 
and {{OMKeyCommitRequest.java:358}}, which are all per\-request.

Two callers are not per\-request, and they are where this matters:

{{KeyManagerImpl.java:868}}, inside the loop that builds the reclaimable key 
list for {{KeyDeletingService}}. That one runs per pending\-delete key on every 
service iteration, so the allocation is repeated across the whole scan.

{{OMDirectoriesPurgeRequestWithFSO.java:191}}, inside the loop over 
{{path.getDeletedSubFilesList\(\)}}, itself nested in a loop over purge paths. 
That request is produced in batches by {{DirectoryDeletingService}}, so one 
request can carry many sub\-files. An earlier revision of this description 
listed it as per\-request, which was wrong.

h3. Verification needed first
This is an allocation observation, not a measurement. Nothing here has been 
benchmarked, and the effect may be too small to observe next to the RocksDB 
reads and the {{OmKeyInfo}} deserialization that dominate the same loop. 
Profiling or a benchmark of the {{KeyDeletingService}} scan should come before 
or with the change, so that the issue is closed on evidence rather than on the 
shape of the code.

h3. Notes
Behavior is unchanged either way: both accessors expose the same block objects, 
so the sum is identical, and the existing delete and purge tests cover it.

HDDS\-16195 covers a separate defect at the {{KeyManagerImpl}} caller: that 
loop walks the same blocks twice and recomputes the replicated size, 
independently of which accessor {{sumBlockLengths}} uses. The two changes do 
not conflict and can land in either order.

Noticed while reviewing HDDS\-16127, which added a second caller of 
{{sumBlockLengths}} in the quota repair recount. That caller only runs for keys 
retaining more than one version, so it is not the motivation here.

Pinned source commit 4766aa8609. Analysis assisted by AI tooling \(Claude Code, 
Opus 5\).


> Avoid the per-group list copy in OMKeyRequest.sumBlockLengths
> -------------------------------------------------------------
>
>                 Key: HDDS-16183
>                 URL: https://issues.apache.org/jira/browse/HDDS-16183
>             Project: Apache Ozone
>          Issue Type: Improvement
>          Components: OM
>            Reporter: Chi-Hsuan Huang
>            Priority: Minor
>
> h3. Problem
> {{OMKeyRequest.sumBlockLengths}} \({{OMKeyRequest.java:887\-897}}\) totals 
> the length of every block a key points to, but reaches those blocks through 
> {{OmKeyLocationInfoGroup.getLocationList\(\)}}, which builds a flattened copy 
> on every call:
> {code}
> public static long sumBlockLengths\(OmKeyInfo omKeyInfo\) {
>   long bytesUsed = 0;
>   for \(OmKeyLocationInfoGroup group: omKeyInfo.getKeyLocationVersions\(\)\) {
>     for \(OmKeyLocationInfo locationInfo : group.getLocationList\(\)\) {
>       bytesUsed \+= QuotaUtil.getReplicatedSize\(
>           locationInfo.getLength\(\), omKeyInfo.getReplicationConfig\(\)\);
>     }
>   }
>   return bytesUsed;
> }
> {code}
> {{getLocationList\(\)}} carries an explicit warning from its author 
> \({{OmKeyLocationInfoGroup.java:103\-112}}\):
> {quote}
> Use this expensive method only when absolutely needed\! It creates a new list 
> so it is not an O\(1\) operation. Use getLocationLists\(\) instead.
> {quote}
> It is implemented as 
> {{locationVersionMap.values\(\).stream\(\).flatMap\(List::stream\).collect\(...\)}},
>  so each call allocates a stream pipeline and a new {{ArrayList}} per version 
> group. Summing lengths does not need a flattened list; 
> {{getLocationLists\(\)}} returns {{locationVersionMap.values\(\)}} directly 
> and an extra nested loop would visit exactly the same block objects.
> h3. Where it is called
> {{sumBlockLengths}} is used to compute released quota in 
> {{OMKeyDeleteRequest.java:165}}, {{OMKeyDeleteRequestWithFSO.java:162}}, 
> {{OMKeysDeleteRequest.java:322}}, {{OmKeysDeleteRequestWithFSO.java:115,156}} 
> and {{OMKeyCommitRequest.java:358}}, which are all per\-request.
> Two callers are not per\-request, and they are where this matters:
> {{KeyManagerImpl.java:868}}, inside the loop that builds the reclaimable key 
> list for {{KeyDeletingService}}. That one runs per pending\-delete key on 
> every service iteration, so the allocation is repeated across the whole scan.
> {{OMDirectoriesPurgeRequestWithFSO.java:191}}, inside the loop over 
> {{path.getDeletedSubFilesList\(\)}}, itself nested in a loop over purge 
> paths. That request is produced in batches by {{DirectoryDeletingService}}, 
> so one request can carry many sub\-files. An earlier revision of this 
> description listed it as per\-request, which was wrong.
> h3. Measurement
> An isolated measurement of {{sumBlockLengths}} alone was taken with a 
> throwaway JUnit harness \(not committed; no new dependency\), counting 
> per\-call thread allocation with {{ThreadMXBean.getThreadAllocatedBytes}} 
> after warmup. Two runs agreed closely. Blocks are 256 MB, replication 
> RATIS/THREE.
> ||key shape||before||after||saved||
> |1 group x 1 block|129 ns / 469 B|14 ns / 0 B|469 B|
> |1 group x 4 blocks|115 ns / 448 B|14 ns / 0 B|448 B|
> |1 group x 32 blocks|252 ns / 784 B|54 ns / 0 B|784 B|
> |4 groups x 4 blocks|339 ns / 1792 B|64 ns / 0 B|1792 B|
> |16 groups x 8 blocks|1579 ns / 7168 B|366 ns / 0 B|7168 B|
> The nested\-loop form allocates nothing: {{getLocationLists\(\)}} returns the 
> live collection and the iterators are scalarized by escape analysis. The 
> current form pays roughly 470 B and 130 ns even for a single\-block key, 
> which is the fixed cost of the stream pipeline.
> With {{ozone.key.deleting.limit.per.task}} at its default of 50000, and a 
> typical key of one version group with four blocks, that is on the order of 22 
> MB of garbage and 6 ms per {{KeyDeletingService}} scan.
> h3. What this does and does not show
> The allocation column is a direct measurement and is the solid part. The 
> timing column is indicative only: this is a plain harness, not JMH, so it has 
> no dead\-code or constant\-folding guards.
> More importantly, this measures the method in isolation, not the 
> {{KeyDeletingService}} scan end to end. It does not establish what share of 
> that scan the change is worth, and 6 ms against 50000 RocksDB reads and 
> {{OmKeyInfo}} deserializations is very likely still noise. The original 
> caveat therefore stands, and no speedup is being claimed: the case for the 
> change is that it follows the accessor's documented contract and removes 
> provably unnecessary allocation, not that it makes deletion faster.
> h3. Original verification note
> This is an allocation observation, not a measurement. Nothing here has been 
> benchmarked, and the effect may be too small to observe next to the RocksDB 
> reads and the {{OmKeyInfo}} deserialization that dominate the same loop. 
> Profiling or a benchmark of the {{KeyDeletingService}} scan should come 
> before or with the change, so that the issue is closed on evidence rather 
> than on the shape of the code.
> h3. Notes
> Behavior is unchanged either way: both accessors expose the same block 
> objects, so the sum is identical, and the existing delete and purge tests 
> cover it.
> HDDS\-16195 covers a separate defect at the {{KeyManagerImpl}} caller: that 
> loop walks the same blocks twice and recomputes the replicated size, 
> independently of which accessor {{sumBlockLengths}} uses. The two changes do 
> not conflict and can land in either order.
> Noticed while reviewing HDDS\-16127, which added a second caller of 
> {{sumBlockLengths}} in the quota repair recount. That caller only runs for 
> keys retaining more than one version, so it is not the motivation here.
> Pinned source commit 4766aa8609. Analysis assisted by AI tooling \(Claude 
> Code, Opus 5\).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to