Ryen created HDDS-15916:
---------------------------

             Summary: Native memory leak in ECKeyOutputStream: 
encoder.release() not called in close(), causing ISA-L native memory 
accumulation
                 Key: HDDS-15916
                 URL: https://issues.apache.org/jira/browse/HDDS-15916
             Project: Apache Ozone
          Issue Type: Bug
          Components: s3gateway
    Affects Versions: 2.1.0
         Environment: OS: CentOS 7 (Linux 3.10 kernel)
JDK: OpenJDK 17.0.17+10-LTS
Ozone: 2.1.0
Hadoop: 3.4.2
Ratis: 3.2.0
ISA-L: 2.32.0
JVM flags: -XX:+UseG1GC -Xmx8g -X2 with 
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000, 
MALLOC_ARENA_MAX=4
S3G uptime before leak became critical: ~26 days
            Reporter: Ryen


The S3 Gateway (S3G) process leaks native memory continuously when handling PUT 
requests to EC (Erasure Coded) buckets. The leak is caused by 
\{{ECKeyOutputStream.close()}} not invoking \{{encoder.release()}}, leaving the 
underlying ISA-L native encoder context (allocated via JNI inside Hadoop's 
\{{NativeRSRawEncoder}}) un-released.

h3. Symptoms

After ~26 days of S3G uptime serving a workload that includes EC-bucket PUTs:

||Metric||Value|| |JVM Xmx|8 GB| |JVM Xms|4 GB| |Heap used|~2.5 GB (\{{jmap 
--heap}})| |DirectByteBuffer (Java NIO Bits)|0 (\{{jshell}} BufferPoolMXBean)| 
|MappedByteBuffer|0| |Process RSS|~57 GB (\{{ps}})| |Number of large anonymous 
mmap segments (>5 GB RSS each)|5| |Total RSS in those 5 segments|~33 GB|

The 5 large \{{[anon]}} segments (virtual size 6-11 GB each, RSS 5-8 GB each) 
are all \{{Private_Dirty}} with only a few KB of \{{LazyFree}}, confirmed by 
{{/proc/<pid>/smaps}}. \{{GC.run}} via \{{jcmd}} does not reduce RSS.

h3. Root cause

{\{ECKeyOutputStream}} creates a \{{RawErasureEncoder}} in its constructor. 
When the EC replication config uses \{{rs}} codec and ISA-L native is 
available, this resolves to \{{NativeRSRawEncoder}}, which wraps Hadoop's 
\{{org.apache.hadoop.io.erasurecode.rawcoder.NativeRSRawEncoder}}. The Hadoop 
encoder allocates native memory (ISA-L encoder context + matrix + scratch 
buffers) via JNI on construction, and that memory is only freed when 
\{{release()}} is called.

However, \{{ECKeyOutputStream.close()}} never calls \{{encoder.release()}}:

{code:java} // 
hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/ECKeyOutputStream.java

public final class ECKeyOutputStream extends KeyOutputStream implements 
KeyMetadataAware {

private final RawErasureEncoder encoder; // line 69

private ECKeyOutputStream(Builder builder) \{ ... this.encoder = 
CodecUtil.createRawEncoderWithFallback( builder.getReplicationConfig()); // 
line 112 ... }

@Override public void close() throws IOException \{ // line 459 if (closed) { 
return; } closed = true; ... try \{ ... } catch (...) \{ ... } finally \{ 
closeCurrentStreamEntry(); blockOutputStreamEntryPool.cleanup(); // <-- 
encoder.release() is missing here } } } \{code}

The \{{release()}} method exists on \{{NativeRSRawEncoder}} (and on the Java 
fallback \{{RSRawEncoder}}), but is never invoked:

{code:java} // 
hadoop-hdds/erasurecode/src/main/java/org/apache/ozone/erasurecode/rawcoder/NativeRSRawEncoder.java

public class NativeRSRawEncoder extends AbstractNativeRawEncoder { private 
org.apache.hadoop.io.erasurecode.rawcoder.NativeRSRawEncoder 
hadoopNativeRSRawEncoder;

@Override public void release() \{ // exists, but never called 
hadoopNativeRSRawEncoder.release(); } } \{code}

Every PUT to an EC bucket therefore leaks one ISA-L encoder context. Over weeks 
of S3G operation this accumulates into the tens-of-GB range observed above.

h3. Why DirectByteBuffer counters show 0

The Hadoop \{{NativeRSRawEncoder}} allocates its scratch buffers via JNI 
(\{{malloc}}/\{{mmap}}) rather than via \{{java.nio.Bits}}, so the leak is 
invisible to \{{java.lang.management.BufferPoolMXBean}} (which reports 
\{{direct = 0}} and \{{mapped = 0}}). The leaked memory shows up only as 
anonymous \{{[anon]}} segments in \{{pmap}} / {{/proc/<pid>/smaps}}.

h3. Why GC.run does not help

The Hadoop native encoder does not register a \{{Cleaner}} / 
\{{PhantomReference}} for its native allocation, so GC cannot reclaim it. Only 
an explicit \{{release()}} call frees the native memory.

h3. Reproduction
h1. Start an Ozone cluster with S3G enabled and ISA-L native available.
h1. Create an EC bucket, e.g. \{{ozone sh bucket create ... --type EC --ec 
rs-3-2-1024k}}.
h1. Drive sustained PUT traffic against the EC bucket via the S3 API (e.g. with 
\{{s3cmd}}, \{{aws s3 cp}}, or a load generator).
h1. Monitor S3G RSS:

{code:bash} while true; do ps -o rss,pid -p $(pgrep -f proc_s3g); sleep 60; 
done \{code}
h1. Observe RSS monotonically increasing well beyond \{{Xmx}}, while heap usage 
stays low (G1 humongous allocations from the S3G \{{byte[]}} buffers are 
reclaimed normally, only native memory grows).

h3. Diagnostic evidence (collected on the affected S3G)
 * {{jhsdb jmap --heap --pid <pid>}}: heap 30% used, G1 healthy

 * {\{jshell}} reading \{{BufferPoolMXBean}}: \{{direct = 0 MB, mapped = 0 MB}}

 * {{jcmd <pid> GC.run}}: RSS unchanged afterwards

 * {{jstack <pid>}}: 253 Java threads, no thread leak

 * {{pmap -x <pid>}}: 5 anonymous \{{[anon]}} segments with RSS 5-8 GB each 
(virtual 6-11 GB each)

 * {{/proc/<pid>/smaps}} for the largest segment: \{noformat} 
7f597d780000-7f5c3e400000 rw-p 00000000 00:00 0 Size: 11547136 kB Rss: 8100436 
kB Private_Dirty: 8100376 kB LazyFree: 60 kB Anonymous: 8100436 kB \{noformat}

 * {{perf record -e syscalls:sys_enter_mmap -p <pid>}}: only 4 KB / 16 KB 
(thread stack) mmaps at steady state, confirming the large segments are 
historical accumulation, not current allocation

 * {{lsof -p <pid>}}: \{{libisal.so.2.0.32}} and \{{libhadoop.so}} are loaded

h3. Suggested fix

Add \{{encoder.release()}} to the \{{finally}} block of 
\{{ECKeyOutputStream.close()}}:

{code:java} @Override public void close() throws IOException \{ if (closed) { 
return; } closed = true; ... try \{ ... } catch (...) \{ ... } finally \{ 
closeCurrentStreamEntry(); blockOutputStreamEntryPool.cleanup(); if (encoder != 
null) { encoder.release(); // <-- fix } } } \{code}

{\{RawErasureEncoder}} already declares \{{release()}} (inherited via 
\{{AbstractRawErasureCoder}}), so the same call works for both 
\{{NativeRSRawEncoder}} (ISA-L) and the pure-Java \{{RSRawEncoder}} fallback.

This is the Ozone-side equivalent of HADOOP-17209, which fixed the same class 
of leak in Hadoop HDFS's \{{ErasureCodingOutputStream}}.

h3. Workaround

Without a code fix, operators can:
h1. Restart S3G periodically (e.g. daily via cron) to reset RSS.
h1. Replace \{{NativeRSRawErasureCoderFactory}} / 
\{{NativeXORRawErasureCoderFactory}} in 
\{{META-INF/services/org.apache.ozone.erasurecode.rawcoder.RawErasureCoderFactory}}
 inside {{hdds-erasurecode-<version>.jar}} with the pure-Java 
\{{RSRawErasureCoderFactory}} / \{{XORRawErasureCoderFactory}}. This avoids the 
JNI allocation entirely but loses ISA-L acceleration.

Neither workaround is ideal; a source-code fix is the proper resolution.

h3. References
 * [HADOOP-17209|https://issues.apache.org/jira/browse/HADOOP-17209]: Erasure 
Coding: Native library memory leak (Hadoop-side fix that Ozone should mirror on 
\{{ECKeyOutputStream}})

 * Ozone source: 
\{{hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/ECKeyOutputStream.java}}

 * Ozone source: 
\{{hadoop-hdds/erasurecode/src/main/java/org/apache/ozone/erasurecode/rawcoder/NativeRSRawEncoder.java}}



--
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