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    // 용언
173    "VV", "VA", "VX", "VCP", "VCN",
174    // 수식언
175    "MM", "MAG", "MAJ",
176    // 독립언
177    "IC",
178    // 관계언
179    "JKS", "JKC", "JKG", "JKO", "JKB", "JKV", "JKQ", "JX", "JC",
180    // 의존형태
181    "EP", "EF", "EC", "ETN", "ETM", "XPN", "XSN", "XSV", "XSA", "XR",
182    // 기호
183    "SF", "SE", "SS", "SP", "SO", "SL", "SH", "SN", "SW",
184    // 분석 불능
185    "NA",
186    // Unknown
187    "UNK", "UNKNOWN",
188    // 기타 확장
189    "*", "NNBC",
190];
191
192/// 인터너 통계
193#[derive(Debug, Clone, Copy)]
194pub struct InternerStats {
195    /// 고유 태그 수
196    pub unique_tags: usize,
197    /// intern 호출 횟수
198    pub intern_calls: usize,
199    /// 캐시 히트 횟수
200    pub cache_hits: usize,
201    /// 캐시 히트율
202    pub hit_rate: f64,
203}
204
205impl InternerStats {
206    /// 통계를 문자열로 포맷
207    #[must_use]
208    pub fn format(&self) -> String {
209        format!(
210            "POS Interner: {} unique tags, {} calls, {:.1}% hit rate",
211            self.unique_tags,
212            self.intern_calls,
213            self.hit_rate * 100.0
214        )
215    }
216}
217
218/// 메모리 사용량 통계
219#[derive(Debug, Clone, Default)]
220pub struct MemoryStats {
221    /// 사전 메모리 (바이트)
222    pub dictionary_bytes: usize,
223    /// Lattice 메모리 (바이트)
224    pub lattice_bytes: usize,
225    /// 풀 메모리 (바이트)
226    pub pool_bytes: usize,
227    /// 캐시 메모리 (바이트)
228    pub cache_bytes: usize,
229    /// 인터너 메모리 (바이트)
230    pub interner_bytes: usize,
231    /// 토큰 메모리 (바이트)
232    pub token_bytes: usize,
233}
234
235impl MemoryStats {
236    /// 총 메모리 추정
237    #[must_use]
238    pub const fn estimate_total(&self) -> usize {
239        self.dictionary_bytes
240            + self.lattice_bytes
241            + self.pool_bytes
242            + self.cache_bytes
243            + self.interner_bytes
244            + self.token_bytes
245    }
246
247    /// 사람이 읽기 좋은 형식으로 포맷
248    #[must_use]
249    pub fn format_human_readable(&self) -> String {
250        format!(
251            "Memory Usage:\n\
252             - Dictionary: {} KB\n\
253             - Lattice: {} KB\n\
254             - Pool: {} KB\n\
255             - Cache: {} KB\n\
256             - Interner: {} KB\n\
257             - Tokens: {} KB\n\
258             - Total: {} KB",
259            self.dictionary_bytes / 1024,
260            self.lattice_bytes / 1024,
261            self.pool_bytes / 1024,
262            self.cache_bytes / 1024,
263            self.interner_bytes / 1024,
264            self.token_bytes / 1024,
265            self.estimate_total() / 1024
266        )
267    }
268}
269
270/// Feature 문자열 중복 제거 캐시
271///
272/// Feature 문자열은 품사 태그보다 다양하지만,
273/// 동일 품사의 엔트리들은 비슷한 feature를 공유합니다.
274#[derive(Debug)]
275pub struct FeatureCache {
276    /// Feature → 인덱스
277    features: RwLock<HashMap<String, u32>>,
278    /// 인덱스 → Feature
279    reverse: RwLock<Vec<String>>,
280    /// 최대 캐시 크기
281    max_size: usize,
282}
283
284impl FeatureCache {
285    /// 새 캐시 생성
286    #[must_use]
287    pub fn new(max_size: usize) -> Self {
288        Self {
289            features: RwLock::new(HashMap::with_capacity(max_size.min(10000))),
290            reverse: RwLock::new(Vec::with_capacity(max_size.min(10000))),
291            max_size,
292        }
293    }
294
295    /// Feature 인터닝
296    ///
297    /// 캐시가 가득 차면 새 feature는 인터닝하지 않고 None 반환
298    #[allow(clippy::significant_drop_tightening)]
299    pub fn intern(&self, feature: &str) -> Option<u32> {
300        // 읽기 잠금으로 먼저 확인
301        {
302            let features = self.features.read();
303            if let Some(&idx) = features.get(feature) {
304                return Some(idx);
305            }
306        }
307
308        // 캐시 크기 확인
309        let len = self.reverse.read().len();
310        if len >= self.max_size {
311            return None;
312        }
313
314        // 쓰기 잠금으로 추가
315        let mut features = self.features.write();
316        let mut reverse = self.reverse.write();
317
318        if let Some(&idx) = features.get(feature) {
319            return Some(idx);
320        }
321
322        if reverse.len() >= self.max_size {
323            return None;
324        }
325
326        let idx = u32::try_from(reverse.len()).ok()?;
327        features.insert(feature.to_string(), idx);
328        reverse.push(feature.to_string());
329        Some(idx)
330    }
331
332    /// 인덱스로 Feature 조회
333    #[must_use]
334    pub fn resolve(&self, idx: u32) -> Option<String> {
335        self.reverse.read().get(idx as usize).cloned()
336    }
337
338    /// 캐시 크기
339    #[must_use]
340    pub fn len(&self) -> usize {
341        self.reverse.read().len()
342    }
343
344    /// 비어있는지 확인
345    #[must_use]
346    pub fn is_empty(&self) -> bool {
347        self.reverse.read().is_empty()
348    }
349
350    /// 메모리 사용량 (바이트)
351    #[must_use]
352    #[allow(clippy::significant_drop_tightening)]
353    pub fn memory_usage(&self) -> usize {
354        let reverse = self.reverse.read();
355        let features = self.features.read();
356
357        let vec_bytes: usize = reverse.iter().map(String::len).sum();
358        let map_overhead = features.capacity() * (std::mem::size_of::<String>() + 4);
359
360        vec_bytes + map_overhead
361    }
362}
363
364impl Default for FeatureCache {
365    fn default() -> Self {
366        Self::new(50000)
367    }
368}
369
370/// 토큰 메모리 사용량 추정
371///
372/// 토큰 벡터의 메모리 사용량을 추정합니다.
373#[must_use]
374pub fn estimate_tokens_memory(tokens: &[crate::tokenizer::Token]) -> usize {
375    let base_size = std::mem::size_of_val(tokens);
376    let string_bytes: usize = tokens
377        .iter()
378        .map(|t| {
379            t.surface.len()
380                + t.pos.len()
381                + t.features.len()
382                + t.reading.as_ref().map_or(0, String::len)
383                + t.lemma.as_ref().map_or(0, String::len)
384                + t.normalized.as_ref().map_or(0, String::len)
385        })
386        .sum();
387
388    base_size + string_bytes
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_pos_tag_interner() {
397        let interner = PosTagInterner::new();
398
399        // 기본 태그는 이미 등록됨
400        let idx1 = interner.intern("NNG");
401        let idx2 = interner.intern("NNG");
402        assert_eq!(idx1, idx2);
403
404        // 새 태그 등록
405        let idx3 = interner.intern("CUSTOM_TAG");
406        assert_ne!(idx1, idx3);
407
408        // 해석
409        assert_eq!(interner.resolve(idx1), Some("NNG".to_string()));
410        assert_eq!(interner.resolve(idx3), Some("CUSTOM_TAG".to_string()));
411    }
412
413    #[test]
414    fn test_pos_interner_stats() {
415        let interner = PosTagInterner::new();
416
417        // 여러 번 호출
418        for _ in 0..100 {
419            interner.intern("NNG");
420            interner.intern("VV");
421        }
422
423        let stats = interner.stats();
424        assert!(stats.unique_tags > 0);
425        assert!(stats.intern_calls > 200); // 초기화 + 200
426        // 초기화 시 ~45개 태그가 미스로 카운트되므로 히트율은 ~0.8
427        assert!(stats.hit_rate > 0.75, "hit_rate: {}", stats.hit_rate);
428    }
429
430    #[test]
431    fn test_feature_cache() {
432        let cache = FeatureCache::new(100);
433
434        let idx1 = cache.intern("NNG,*,T,테스트,*,*,*,*");
435        assert!(idx1.is_some());
436
437        let idx2 = cache.intern("NNG,*,T,테스트,*,*,*,*");
438        assert_eq!(idx1, idx2);
439
440        assert_eq!(cache.resolve(idx1.unwrap()), Some("NNG,*,T,테스트,*,*,*,*".to_string()));
441    }
442
443    #[test]
444    fn test_feature_cache_max_size() {
445        let cache = FeatureCache::new(2);
446
447        assert!(cache.intern("feature1").is_some());
448        assert!(cache.intern("feature2").is_some());
449        // 캐시가 가득 차면 새 항목은 추가되지 않음
450        assert!(cache.intern("feature3").is_none());
451    }
452
453    #[test]
454    fn test_memory_stats_format() {
455        let stats = MemoryStats {
456            dictionary_bytes: 100 * 1024,
457            lattice_bytes: 10 * 1024,
458            pool_bytes: 5 * 1024,
459            cache_bytes: 20 * 1024,
460            interner_bytes: 1 * 1024,
461            token_bytes: 2 * 1024,
462        };
463
464        let formatted = stats.format_human_readable();
465        assert!(formatted.contains("Dictionary: 100 KB"));
466        assert!(formatted.contains("Total: 138 KB"));
467    }
468
469    #[test]
470    fn test_common_pos_tags_preloaded() {
471        let interner = PosTagInterner::new();
472
473        // 일반적인 태그는 이미 로드됨
474        assert!(interner.len() > 30);
475
476        // 모든 기본 태그가 등록되어 있어야 함
477        for tag in COMMON_POS_TAGS {
478            let idx = interner.intern(tag);
479            assert!(idx < 100);
480        }
481    }
482}