Skip to content

Commit a47fa85

Browse files
authored
Merge pull request #21 from ProjectVG/perf/LOH-optimize
Perf : LOH 최적화
2 parents 0d9b594 + 044083c commit a47fa85

File tree

5 files changed

+256
-54
lines changed

5 files changed

+256
-54
lines changed

ProjectVG.Application/Models/Chat/ChatSegment.cs

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,25 @@
33

44
namespace ProjectVG.Application.Models.Chat
55
{
6-
public record ChatSegment
6+
public sealed class ChatSegment : IDisposable
77
{
88

9-
public string Content { get; init; } = string.Empty;
9+
public string Content { get; private set; } = string.Empty;
1010

11-
public int Order { get; init; }
11+
public int Order { get; private set; }
1212

13-
public string? Emotion { get; init; }
13+
public string? Emotion { get; private set; }
1414

15-
public List<string>? Actions { get; init; }
15+
public List<string>? Actions { get; private set; }
1616

17-
public byte[]? AudioData { get; init; }
18-
public string? AudioContentType { get; init; }
19-
public float? AudioLength { get; init; }
17+
public byte[]? AudioData { get; private set; }
18+
public string? AudioContentType { get; private set; }
19+
public float? AudioLength { get; private set; }
2020

21-
// 스트림 기반 음성 데이터 처리를 위한 새로운 프로퍼티
22-
public IMemoryOwner<byte>? AudioMemoryOwner { get; init; }
23-
public int AudioDataSize { get; init; }
21+
// LOH 방지를 위한 ArrayPool 기반 메모리 관리
22+
internal IMemoryOwner<byte>? AudioMemoryOwner { get; private set; }
23+
internal int AudioDataSize { get; private set; }
24+
private bool _disposed;
2425

2526

2627

@@ -30,14 +31,13 @@ public record ChatSegment
3031
public bool HasEmotion => !string.IsNullOrEmpty(Emotion);
3132
public bool HasActions => Actions != null && Actions.Any();
3233

33-
/// <summary>
34-
/// 메모리 효율적인 방식으로 음성 데이터에 접근합니다
35-
/// </summary>
3634
public ReadOnlySpan<byte> GetAudioSpan()
3735
{
3836
if (AudioMemoryOwner != null && AudioDataSize > 0)
3937
{
40-
return AudioMemoryOwner.Memory.Span.Slice(0, AudioDataSize);
38+
var memory = AudioMemoryOwner.Memory;
39+
var safeSize = Math.Min(AudioDataSize, memory.Length);
40+
return memory.Span.Slice(0, safeSize);
4141
}
4242
if (AudioData != null)
4343
{
@@ -48,6 +48,8 @@ public ReadOnlySpan<byte> GetAudioSpan()
4848

4949

5050

51+
private ChatSegment() { }
52+
5153
public static ChatSegment Create(string content, string? emotion = null, List<string>? actions = null, int order = 0)
5254
{
5355
return new ChatSegment
@@ -69,36 +71,80 @@ public static ChatSegment CreateAction(string action, int order = 0)
6971
return Create("", null, new List<string> { action }, order);
7072
}
7173

72-
// Method to add audio data (returns new record instance)
7374
public ChatSegment WithAudioData(byte[] audioData, string audioContentType, float audioLength)
7475
{
75-
return this with
76+
return new ChatSegment
7677
{
78+
Content = this.Content,
79+
Order = this.Order,
80+
Emotion = this.Emotion,
81+
Actions = this.Actions,
7782
AudioData = audioData,
7883
AudioContentType = audioContentType,
7984
AudioLength = audioLength
8085
};
8186
}
8287

83-
/// <summary>
84-
/// 메모리 효율적인 방식으로 음성 데이터를 추가합니다 (LOH 방지)
85-
/// </summary>
88+
// 주의: 원본 인스턴스의 AudioMemoryOwner 해제됨
8689
public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
8790
{
88-
return this with
91+
if (audioMemoryOwner is null)
92+
throw new ArgumentNullException(nameof(audioMemoryOwner));
93+
94+
if (audioDataSize < 0 || audioDataSize > audioMemoryOwner.Memory.Length)
95+
throw new ArgumentOutOfRangeException(
96+
nameof(audioDataSize),
97+
audioDataSize,
98+
$"audioDataSize는 0 이상 {audioMemoryOwner.Memory.Length} 이하여야 합니다.");
99+
100+
// 기존 소유자 해제 및 상태 정리
101+
this.AudioMemoryOwner?.Dispose();
102+
this.AudioMemoryOwner = null;
103+
this.AudioDataSize = 0;
104+
105+
return new ChatSegment
89106
{
107+
Content = this.Content,
108+
Order = this.Order,
109+
Emotion = this.Emotion,
110+
Actions = this.Actions,
90111
AudioMemoryOwner = audioMemoryOwner,
91112
AudioDataSize = audioDataSize,
92113
AudioContentType = audioContentType,
93114
AudioLength = audioLength,
94-
// 기존 AudioData는 null로 설정하여 중복 저장 방지
95115
AudioData = null
96116
};
97117
}
98118

99119
/// <summary>
100-
/// 음성 데이터를 배열로 변환합니다 (필요한 경우에만 사용)
120+
/// 오디오 메모리를 부착한 새 인스턴스 생성 (원본 불변)
101121
/// </summary>
122+
public ChatSegment AttachAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audioDataSize, string audioContentType, float audioLength)
123+
{
124+
if (audioMemoryOwner is null)
125+
throw new ArgumentNullException(nameof(audioMemoryOwner));
126+
127+
if (audioDataSize < 0 || audioDataSize > audioMemoryOwner.Memory.Length)
128+
throw new ArgumentOutOfRangeException(
129+
nameof(audioDataSize),
130+
audioDataSize,
131+
$"audioDataSize는 0 이상 {audioMemoryOwner.Memory.Length} 이하여야 합니다.");
132+
133+
return new ChatSegment
134+
{
135+
Content = this.Content,
136+
Order = this.Order,
137+
Emotion = this.Emotion,
138+
Actions = this.Actions,
139+
AudioMemoryOwner = audioMemoryOwner,
140+
AudioDataSize = audioDataSize,
141+
AudioContentType = audioContentType,
142+
AudioLength = audioLength,
143+
AudioData = null
144+
};
145+
}
146+
147+
// 필요시만 사용 - LOH 위험 있음
102148
public byte[]? GetAudioDataAsArray()
103149
{
104150
if (AudioData != null)
@@ -115,12 +161,11 @@ public ChatSegment WithAudioMemory(IMemoryOwner<byte> audioMemoryOwner, int audi
115161
return null;
116162
}
117163

118-
/// <summary>
119-
/// 리소스 해제 (IMemoryOwner 해제)
120-
/// </summary>
121164
public void Dispose()
122165
{
166+
if (_disposed) return;
123167
AudioMemoryOwner?.Dispose();
168+
_disposed = true;
124169
}
125170
}
126171
}

ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,14 @@ public async Task ProcessAsync(ChatProcessContext context)
4646
var processedCount = 0;
4747

4848
foreach (var (idx, ttsResult) in ttsResults.OrderBy(x => x.idx)) {
49-
if (ttsResult.Success == true && ttsResult.AudioData != null) {
49+
if (ttsResult.Success == true && ttsResult.AudioMemoryOwner != null) {
5050
var segment = context.Segments?[idx];
5151
if (segment != null && context.Segments != null) {
52-
context.Segments[idx] = segment.WithAudioData(ttsResult.AudioData, ttsResult.ContentType!, ttsResult.AudioLength ?? 0f);
52+
context.Segments[idx] = segment.WithAudioMemory(
53+
ttsResult.AudioMemoryOwner,
54+
ttsResult.AudioDataSize,
55+
ttsResult.ContentType!,
56+
ttsResult.AudioLength ?? 0f);
5357
}
5458

5559
if (ttsResult.AudioLength.HasValue) {

ProjectVG.Infrastructure/Integrations/TextToSpeechClient/Models/TextToSpeechResponse.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
using System.Text.Json.Serialization;
2+
using System.Buffers;
23

34
namespace ProjectVG.Infrastructure.Integrations.TextToSpeechClient.Models
45
{
6+
/// <summary>
7+
/// TTS API 응답 모델 - IMemoryOwner 기반 메모리 관리
8+
/// </summary>
59
public class TextToSpeechResponse
610
{
711
/// <summary>
@@ -17,11 +21,24 @@ public class TextToSpeechResponse
1721
public string? ErrorMessage { get; set; }
1822

1923
/// <summary>
20-
/// 오디오 데이터 (바이트 배열)
24+
/// 오디오 데이터 (바이트 배열) - 레거시 호환성용
2125
/// </summary>
2226
[JsonIgnore]
2327
public byte[]? AudioData { get; set; }
2428

29+
/// <summary>
30+
/// ArrayPool 기반 오디오 메모리 소유자 (LOH 방지)
31+
/// 주의: ChatSegment로 이전하지 않을 경우 직접 Dispose() 필요
32+
/// </summary>
33+
[JsonIgnore]
34+
public IMemoryOwner<byte>? AudioMemoryOwner { get; set; }
35+
36+
/// <summary>
37+
/// 실제 오디오 데이터 크기
38+
/// </summary>
39+
[JsonIgnore]
40+
public int AudioDataSize { get; set; }
41+
2542
/// <summary>
2643
/// 오디오 길이 (초)
2744
/// </summary>
@@ -39,5 +56,20 @@ public class TextToSpeechResponse
3956
/// </summary>
4057
[JsonIgnore]
4158
public int StatusCode { get; set; } = 200;
59+
60+
/// <summary>
61+
/// 오디오 메모리 소유권을 안전하게 가져갑니다
62+
/// </summary>
63+
public bool TryTakeAudioOwner(out IMemoryOwner<byte>? owner, out int size)
64+
{
65+
owner = AudioMemoryOwner;
66+
size = AudioDataSize;
67+
68+
// 소유권 이전 후 현재 객체에서 제거하여 중복 해제 방지
69+
AudioMemoryOwner = null;
70+
AudioDataSize = 0;
71+
72+
return owner != null;
73+
}
4274
}
4375
}

ProjectVG.Infrastructure/Integrations/TextToSpeechClient/TextToSpeechClient.cs

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ public async Task<TextToSpeechResponse> TextToSpeechAsync(TextToSpeechRequest re
5050
return voiceResponse;
5151
}
5252

53-
// 스트림 기반으로 음성 데이터 읽기 (LOH 방지)
54-
voiceResponse.AudioData = await ReadAudioDataWithPoolAsync(response.Content);
53+
// ArrayPool 기반으로 음성 데이터 읽기 (LOH 방지)
54+
var (memoryOwner, dataSize) = await ReadAudioDataWithPoolAsync(response.Content);
55+
voiceResponse.AudioMemoryOwner = memoryOwner;
56+
voiceResponse.AudioDataSize = dataSize;
5557
voiceResponse.ContentType = response.Content.Headers.ContentType?.ToString();
5658

5759
if (response.Headers.Contains("X-Audio-Length"))
@@ -64,7 +66,7 @@ public async Task<TextToSpeechResponse> TextToSpeechAsync(TextToSpeechRequest re
6466
}
6567

6668
_logger.LogDebug("[TTS][Response] 오디오 길이: {AudioLength:F2}초, ContentType: {ContentType}, 바이트: {Length}, 소요시간: {Elapsed}ms",
67-
voiceResponse.AudioLength, voiceResponse.ContentType, voiceResponse.AudioData?.Length ?? 0, elapsed);
69+
voiceResponse.AudioLength, voiceResponse.ContentType, voiceResponse.AudioDataSize, elapsed);
6870

6971
return voiceResponse;
7072
}
@@ -82,44 +84,63 @@ public async Task<TextToSpeechResponse> TextToSpeechAsync(TextToSpeechRequest re
8284
/// <summary>
8385
/// ArrayPool을 사용하여 스트림 기반으로 음성 데이터를 읽습니다 (LOH 할당 방지)
8486
/// </summary>
85-
private async Task<byte[]?> ReadAudioDataWithPoolAsync(HttpContent content)
87+
private async Task<(IMemoryOwner<byte>?, int)> ReadAudioDataWithPoolAsync(HttpContent content)
8688
{
8789
const int chunkSize = 32768; // 32KB 청크 크기
88-
byte[]? buffer = null;
89-
MemoryStream? memoryStream = null;
90+
byte[]? readBuffer = null;
91+
IMemoryOwner<byte>? owner = null;
9092

9193
try
9294
{
93-
buffer = _arrayPool.Rent(chunkSize);
94-
memoryStream = new MemoryStream();
95-
95+
readBuffer = _arrayPool.Rent(chunkSize);
9696
using var stream = await content.ReadAsStreamAsync();
97-
int bytesRead;
9897

99-
// 청크 단위로 데이터 읽어서 MemoryStream에 복사
100-
while ((bytesRead = await stream.ReadAsync(buffer, 0, chunkSize)) > 0)
98+
// 초기 버퍼 렌트(증분 확장 전략)
99+
owner = MemoryPool<byte>.Shared.Rent(chunkSize);
100+
int total = 0;
101+
while (true)
102+
{
103+
// 여유 공간 없으면 확장
104+
if (total == owner.Memory.Length)
105+
{
106+
var newOwner = MemoryPool<byte>.Shared.Rent(Math.Min(owner.Memory.Length * 2, int.MaxValue));
107+
owner.Memory.Span.Slice(0, total).CopyTo(newOwner.Memory.Span);
108+
owner.Dispose();
109+
owner = newOwner;
110+
}
111+
112+
int toRead = Math.Min(chunkSize, owner.Memory.Length - total);
113+
int bytesRead = await stream.ReadAsync(readBuffer, 0, toRead);
114+
if (bytesRead == 0) break;
115+
readBuffer.AsSpan(0, bytesRead).CopyTo(owner.Memory.Span.Slice(total));
116+
total += bytesRead;
117+
}
118+
119+
if (total == 0)
101120
{
102-
await memoryStream.WriteAsync(buffer, 0, bytesRead);
121+
owner.Dispose();
122+
_logger.LogDebug("[TTS][ArrayPool] 비어있는 오디오 스트림");
123+
return (null, 0);
103124
}
104125

105-
var result = memoryStream.ToArray();
106126
_logger.LogDebug("[TTS][ArrayPool] 음성 데이터 읽기 완료: {Size} bytes, 청크 크기: {ChunkSize}",
107-
result.Length, chunkSize);
127+
total, chunkSize);
108128

109-
return result;
129+
return (owner, total);
110130
}
111131
catch (Exception ex)
112132
{
113133
_logger.LogError(ex, "[TTS][ArrayPool] 음성 데이터 읽기 실패");
114-
return null;
134+
owner?.Dispose();
135+
return (null, 0);
115136
}
116137
finally
117138
{
118-
if (buffer != null)
139+
if (readBuffer != null)
119140
{
120-
_arrayPool.Return(buffer);
141+
_arrayPool.Return(readBuffer);
121142
}
122-
memoryStream?.Dispose();
143+
// owner는 정상 경로에서 호출자에게 반환됨. 예외 시 위에서 Dispose 처리.
123144
}
124145
}
125146

0 commit comments

Comments
 (0)