Skip to main content

mecab_ko_core/
memory.rs

1//! # Memory Optimization Module
2//!
3//! 메모리 사용량 측정 및 최적화 유틸리티
4//!
5//! ## 주요 기능
6//!
7//! - **POS Tag Interning**: 품사 태그 문자열 중복 제거
8//! - **Memory Stats**: 메모리 사용량 측정
9//! - **Feature Deduplication**: Feature 문자열 중복 제거
10//!
11//! ## Example
12//!
13//! ```rust
14//! use mecab_ko_core::memory::{PosTagInterner, MemoryStats};
15//!
16//! let interner = PosTagInterner::new();
17//! let sym = interner.intern("NNG");
18//! assert_eq!(interner.resolve(sym), Some("NNG".to_string()));
19//!
20//! let stats = MemoryStats::default();
21//! println!("Memory: {} bytes", stats.estimate_total());
22//! ```
23
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicUsize, Ordering};
26
27use parking_lot::RwLock;
28
29/// 품사 태그 인터너
30///
31/// `MeCab` 품사 태그는 약 45개로 제한되어 있어 인터닝에 적합합니다.
32/// 스레드 안전하며 여러 토크나이저에서 공유 가능합니다.
33#[derive(Debug)]
34pub struct PosTagInterner {
35    /// 품사 태그 → 인덱스 매핑
36    tags: RwLock<HashMap<String, u16>>,
37    /// 인덱스 → 품사 태그 매핑 (역방향)
38    reverse: RwLock<Vec<String>>,
39    /// 통계: intern 호출 횟수
40    intern_count: AtomicUsize,
41    /// 통계: 캐시 히트 횟수
42    hit_count: AtomicUsize,
43}
44
45impl PosTagInterner {
46    /// 새 인터너 생성
47    ///
48    /// 일반적인 품사 태그를 사전 등록합니다.
49    #[must_use]
50    pub fn new() -> Self {
51        let interner = Self {
52            tags: RwLock::new(HashMap::with_capacity(64)),
53            reverse: RwLock::new(Vec::with_capacity(64)),
54            intern_count: AtomicUsize::new(0),
55            hit_count: AtomicUsize::new(0),
56        };
57
58        // 일반적인 품사 태그 사전 등록
59        for tag in COMMON_POS_TAGS {
60            interner.intern(tag);
61        }
62
63        interner
64    }
65
66    /// 품사 태그 인터닝
67    ///
68    /// 이미 존재하면 기존 인덱스 반환, 새로우면 등록 후 인덱스 반환
69    #[allow(clippy::significant_drop_tightening)]
70    pub fn intern(&self, tag: &str) -> u16 {
71        self.intern_count.fetch_add(1, Ordering::Relaxed);
72
73        // 읽기 잠금으로 먼저 확인
74        {
75            let tags = self.tags.read();
76            if let Some(&idx) = tags.get(tag) {
77                self.hit_count.fetch_add(1, Ordering::Relaxed);
78                return idx;
79            }
80        }
81
82        // 없으면 쓰기 잠금으로 추가
83        let mut tags = self.tags.write();
84        let mut reverse = self.reverse.write();
85
86        // Double-check after acquiring write lock
87        if let Some(&idx) = tags.get(tag) {
88            self.hit_count.fetch_add(1, Ordering::Relaxed);
89            return idx;
90        }
91
92        let idx = u16::try_from(reverse.len()).unwrap_or(u16::MAX);
93        tags.insert(tag.to_string(), idx);
94        reverse.push(tag.to_string());
95        idx
96    }
97
98    /// 인덱스로 품사 태그 조회
99    #[must_use]
100    pub fn resolve(&self, idx: u16) -> Option<String> {
101        let reverse = self.reverse.read();
102        reverse.get(idx as usize).cloned()
103    }
104
105    /// 인덱스로 품사 태그 참조 (복사 없이)
106    pub fn resolve_ref<F, R>(&self, idx: u16, f: F) -> Option<R>
107    where
108        F: FnOnce(&str) -> R,
109    {
110        let reverse = self.reverse.read();
111        reverse.get(idx as usize).map(|s| f(s.as_str()))
112    }
113
114    /// 등록된 품사 태그 수
115    #[must_use]
116    pub fn len(&self) -> usize {
117        self.reverse.read().len()
118    }
119
120    /// 비어있는지 확인
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.reverse.read().is_empty()
124    }
125
126    /// 통계 정보
127    #[must_use]
128    #[allow(clippy::cast_precision_loss)]
129    pub fn stats(&self) -> InternerStats {
130        let intern_count = self.intern_count.load(Ordering::Relaxed);
131        let hit_count = self.hit_count.load(Ordering::Relaxed);
132        InternerStats {
133            unique_tags: self.len(),
134            intern_calls: intern_count,
135            cache_hits: hit_count,
136            hit_rate: if intern_count > 0 {
137                hit_count as f64 / intern_count as f64
138            } else {
139                0.0
140            },
141        }
142    }
143
144    /// 메모리 사용량 추정 (바이트)
145    #[must_use]
146    #[allow(clippy::significant_drop_tightening)]
147    pub fn memory_usage(&self) -> usize {
148        let reverse = self.reverse.read();
149        let tags = self.tags.read();
150
151        // Vec capacity
152        let vec_overhead = reverse.capacity() * std::mem::size_of::<String>();
153        // String contents
154        let string_bytes: usize = reverse.iter().map(String::len).sum();
155        // HashMap overhead
156        let map_overhead = tags.capacity() * (std::mem::size_of::<String>() + 2);
157
158        vec_overhead + string_bytes + map_overhead
159    }
160}
161
162impl Default for PosTagInterner {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168/// 일반적인 품사 태그 (세종 품사 체계 + `MeCab` 확장)
169const COMMON_POS_TAGS: &[&str] = &[
170    // 체언
171    "NNG", "NNP", "NNB", "NR", "NP", // 용언
172    "VV", "VA", "VX", "VCP", "VCN", // 수식언
173    "MM", "MAG", "MAJ", // 독립언
174    "IC",  // 관계언
175    "JKS", "JKC", "JKG", "JKO", "JKB", "JKV", "JKQ", "JX", "JC", // 의존형태
176    "EP", "EF", "EC", "ETN", "ETM", "XPN", "XSN", "XSV", "XSA", "XR", // 기호
177    "SF", "SE", "SS", "SP", "SO", "SL", "SH", "SN", "SW", // 분석 불능
178    "NA", // Unknown
179    "UNK", "UNKNOWN", // 기타 확장
180    "*", "NNBC",
181];
182
183/// 인터너 통계
184#[derive(Debug, Clone, Copy)]
185pub struct InternerStats {
186    /// 고유 태그 수
187    pub unique_tags: usize,
188    /// intern 호출 횟수
189    pub intern_calls: usize,
190    /// 캐시 히트 횟수
191    pub cache_hits: usize,
192    /// 캐시 히트율
193    pub hit_rate: f64,
194}
195
196impl InternerStats {
197    /// 통계를 문자열로 포맷
198    #[must_use]
199    pub fn format(&self) -> String {
200        format!(
201            "POS Interner: {} unique tags, {} calls, {:.1}% hit rate",
202            self.unique_tags,
203            self.intern_calls,
204            self.hit_rate * 100.0
205        )
206    }
207}
208
209/// 메모리 사용량 통계
210#[derive(Debug, Clone, Default)]
211pub struct MemoryStats {
212    /// 사전 메모리 (바이트)
213    pub dictionary_bytes: usize,
214    /// Lattice 메모리 (바이트)
215    pub lattice_bytes: usize,
216    /// 풀 메모리 (바이트)
217    pub pool_bytes: usize,
218    /// 캐시 메모리 (바이트)
219    pub cache_bytes: usize,
220    /// 인터너 메모리 (바이트)
221    pub interner_bytes: usize,
222    /// 토큰 메모리 (바이트)
223    pub token_bytes: usize,
224}
225
226impl MemoryStats {
227    /// 총 메모리 추정
228    #[must_use]
229    pub const fn estimate_total(&self) -> usize {
230        self.dictionary_bytes
231            + self.lattice_bytes
232            + self.pool_bytes
233            + self.cache_bytes
234            + self.interner_bytes
235            + self.token_bytes
236    }
237
238    /// 사람이 읽기 좋은 형식으로 포맷
239    #[must_use]
240    pub fn format_human_readable(&self) -> String {
241        format!(
242            "Memory Usage:\n\
243             - Dictionary: {} KB\n\
244             - Lattice: {} KB\n\
245             - Pool: {} KB\n\
246             - Cache: {} KB\n\
247             - Interner: {} KB\n\
248             - Tokens: {} KB\n\
249             - Total: {} KB",
250            self.dictionary_bytes / 1024,
251            self.lattice_bytes / 1024,
252            self.pool_bytes / 1024,
253            self.cache_bytes / 1024,
254            self.interner_bytes / 1024,
255            self.token_bytes / 1024,
256            self.estimate_total() / 1024
257        )
258    }
259}
260
261/// Feature 문자열 중복 제거 캐시
262///
263/// Feature 문자열은 품사 태그보다 다양하지만,
264/// 동일 품사의 엔트리들은 비슷한 feature를 공유합니다.
265#[derive(Debug)]
266pub struct FeatureCache {
267    /// Feature → 인덱스
268    features: RwLock<HashMap<String, u32>>,
269    /// 인덱스 → Feature
270    reverse: RwLock<Vec<String>>,
271    /// 최대 캐시 크기
272    max_size: usize,
273}
274
275impl FeatureCache {
276    /// 새 캐시 생성
277    #[must_use]
278    pub fn new(max_size: usize) -> Self {
279        Self {
280            features: RwLock::new(HashMap::with_capacity(max_size.min(10000))),
281            reverse: RwLock::new(Vec::with_capacity(max_size.min(10000))),
282            max_size,
283        }
284    }
285
286    /// Feature 인터닝
287    ///
288    /// 캐시가 가득 차면 새 feature는 인터닝하지 않고 None 반환
289    #[allow(clippy::significant_drop_tightening)]
290    pub fn intern(&self, feature: &str) -> Option<u32> {
291        // 읽기 잠금으로 먼저 확인
292        {
293            let features = self.features.read();
294            if let Some(&idx) = features.get(feature) {
295                return Some(idx);
296            }
297        }
298
299        // 캐시 크기 확인
300        let len = self.reverse.read().len();
301        if len >= self.max_size {
302            return None;
303        }
304
305        // 쓰기 잠금으로 추가
306        let mut features = self.features.write();
307        let mut reverse = self.reverse.write();
308
309        if let Some(&idx) = features.get(feature) {
310            return Some(idx);
311        }
312
313        if reverse.len() >= self.max_size {
314            return None;
315        }
316
317        let idx = u32::try_from(reverse.len()).ok()?;
318        features.insert(feature.to_string(), idx);
319        reverse.push(feature.to_string());
320        Some(idx)
321    }
322
323    /// 인덱스로 Feature 조회
324    #[must_use]
325    pub fn resolve(&self, idx: u32) -> Option<String> {
326        self.reverse.read().get(idx as usize).cloned()
327    }
328
329    /// 캐시 크기
330    #[must_use]
331    pub fn len(&self) -> usize {
332        self.reverse.read().len()
333    }
334
335    /// 비어있는지 확인
336    #[must_use]
337    pub fn is_empty(&self) -> bool {
338        self.reverse.read().is_empty()
339    }
340
341    /// 메모리 사용량 (바이트)
342    #[must_use]
343    #[allow(clippy::significant_drop_tightening)]
344    pub fn memory_usage(&self) -> usize {
345        let reverse = self.reverse.read();
346        let features = self.features.read();
347
348        let vec_bytes: usize = reverse.iter().map(String::len).sum();
349        let map_overhead = features.capacity() * (std::mem::size_of::<String>() + 4);
350
351        vec_bytes + map_overhead
352    }
353}
354
355impl Default for FeatureCache {
356    fn default() -> Self {
357        Self::new(50000)
358    }
359}
360
361/// 토큰 메모리 사용량 추정
362///
363/// 토큰 벡터의 메모리 사용량을 추정합니다.
364#[must_use]
365pub fn estimate_tokens_memory(tokens: &[crate::tokenizer::Token]) -> usize {
366    let base_size = std::mem::size_of_val(tokens);
367    let string_bytes: usize = tokens
368        .iter()
369        .map(|t| {
370            t.surface.len()
371                + t.pos.len()
372                + t.features.len()
373                + t.reading.as_ref().map_or(0, String::len)
374                + t.lemma.as_ref().map_or(0, String::len)
375                + t.normalized.as_ref().map_or(0, String::len)
376        })
377        .sum();
378
379    base_size + string_bytes
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_pos_tag_interner() {
388        let interner = PosTagInterner::new();
389
390        // 기본 태그는 이미 등록됨
391        let idx1 = interner.intern("NNG");
392        let idx2 = interner.intern("NNG");
393        assert_eq!(idx1, idx2);
394
395        // 새 태그 등록
396        let idx3 = interner.intern("CUSTOM_TAG");
397        assert_ne!(idx1, idx3);
398
399        // 해석
400        assert_eq!(interner.resolve(idx1), Some("NNG".to_string()));
401        assert_eq!(interner.resolve(idx3), Some("CUSTOM_TAG".to_string()));
402    }
403
404    #[test]
405    fn test_pos_interner_stats() {
406        let interner = PosTagInterner::new();
407
408        // 여러 번 호출
409        for _ in 0..100 {
410            interner.intern("NNG");
411            interner.intern("VV");
412        }
413
414        let stats = interner.stats();
415        assert!(stats.unique_tags > 0);
416        assert!(stats.intern_calls > 200); // 초기화 + 200
417                                           // 초기화 시 ~45개 태그가 미스로 카운트되므로 히트율은 ~0.8
418        assert!(stats.hit_rate > 0.75, "hit_rate: {}", stats.hit_rate);
419    }
420
421    #[test]
422    fn test_feature_cache() {
423        let cache = FeatureCache::new(100);
424
425        let idx1 = cache.intern("NNG,*,T,테스트,*,*,*,*");
426        assert!(idx1.is_some());
427
428        let idx2 = cache.intern("NNG,*,T,테스트,*,*,*,*");
429        assert_eq!(idx1, idx2);
430
431        assert_eq!(
432            cache.resolve(idx1.unwrap()),
433            Some("NNG,*,T,테스트,*,*,*,*".to_string())
434        );
435    }
436
437    #[test]
438    fn test_feature_cache_max_size() {
439        let cache = FeatureCache::new(2);
440
441        assert!(cache.intern("feature1").is_some());
442        assert!(cache.intern("feature2").is_some());
443        // 캐시가 가득 차면 새 항목은 추가되지 않음
444        assert!(cache.intern("feature3").is_none());
445    }
446
447    #[test]
448    fn test_memory_stats_format() {
449        let stats = MemoryStats {
450            dictionary_bytes: 100 * 1024,
451            lattice_bytes: 10 * 1024,
452            pool_bytes: 5 * 1024,
453            cache_bytes: 20 * 1024,
454            interner_bytes: 1024,
455            token_bytes: 2 * 1024,
456        };
457
458        let formatted = stats.format_human_readable();
459        assert!(formatted.contains("Dictionary: 100 KB"));
460        assert!(formatted.contains("Total: 138 KB"));
461    }
462
463    #[test]
464    fn test_common_pos_tags_preloaded() {
465        let interner = PosTagInterner::new();
466
467        // 일반적인 태그는 이미 로드됨
468        assert!(interner.len() > 30);
469
470        // 모든 기본 태그가 등록되어 있어야 함
471        for tag in COMMON_POS_TAGS {
472            let idx = interner.intern(tag);
473            assert!(idx < 100);
474        }
475    }
476}