가비지 컬렉션의 비용을 CPU와 메모리 관점에서 분해하고, 일시정지 시간 중심 관찰이 왜 한계에 부딪히는지 설명한 뒤, OpenJDK 26의 표준 API로 명시적 GC CPU 비용을 계측·분석하는 방법을 소개한다.
URL: https://norlinder.nu/posts/GC-Cost-CPU-vs-Memory/
Title: 가비지 컬렉션에서 CPU-메모리 관계 해부하기
리습에서 가비지 컬렉션(GC)이 대중화된 지 거의 70년이 지난 지금까지, 관리형 런타임은 개발자에게 일종의 마법—자동 메모리 관리—를 제공해 왔다. 이는 프로그래머를 복잡한 수명 주기 관리로부터 해방했다. 이러한 아이디어는 다른 많은 개념과 함께 스몰토크의 설계에도 영향을 미쳤다. 그리고 이 계보를 따라 스몰토크는 내가 매일 개선하는 언어이자 런타임인 자바의 저자들에게 영감을 준 여러 언어 중 하나이기도 했다.
프로그래머는 자유로워졌지만 CPU는 그렇지 않았다. GC는 메모리를 회수하는 데 있어 임계 경로(critical path)에 놓이게 되었고, 영원히 미룰 수 없는 부채를 쌓아 갔다. 수십 년 동안 이 부채를 청산하는 방법은 애플리케이션을 완전히 멈추는 것, 즉 GC 용어로 “세상을 멈추기(stop-the-world)”였다. 컬렉터는 애플리케이션을 멈춘 뒤 힙을 스캔하여 살아있는 데이터를 식별하고 재사용 가능한 메모리를 회수한다. 단일 코어 시대에는 일시정지 시간(pause time)이 머신 부하의 신뢰할 만한 대리 지표로 기능했다.
GC가 성능에 미치는 영향을 논하려면, 그림 1에 나타난 것처럼 이를 세 가지 차원으로 분해해야 한다.
살아있는 데이터를 찾기 위한 객체 그래프 순회, 여유 공간을 만들기 위한 메모리 재배치, 참조 업데이트 등과 같은 작업을 수행하는 전용 GC 스레드가 소비하는 CPU 사이클.
특정 GC 기능을 지원하기 위해 애플리케이션 코드에 직접 삽입되는 코드가 있을 수 있다. 이는 흔히 배리어(barrier)라고 불리며, 참조 카운팅, 객체 나이 추적(세대, generations), 객체가 동시에 이동할 때 힙 일관성 보장 같은 기능에 필요하다.
GC는 메모리 서브시스템에도 영향을 준다. 애플리케이션 데이터를 CPU 캐시에서 축출하여 성능을 저하시킬 수도 있고, 반대로 객체를 재배치해 공간 지역성(spatial locality)을 개선함으로써 성능을 향상시킬 수도 있다.
암묵적 GC 비용을 측정하는 일은 어렵다. Blackburn과 Hosking(2004) [1]은 비교를 위해 배리어가 없는 기준선을 마련하고자 (연구용으로 최적화된 VM인) Jikes RVM을 확장했다. 하지만 이런 접근은 OpenJDK처럼 성능 최적화된 VM에는 쉽게 적용되기 어렵다.
다음에서 보이겠지만, 명시적 GC 비용의 구성 요소가 확장되면서 GC 일시정지 시간은 계산 효율을 나타내는 강력한 대리 지표가 되기 어려워졌다. 반면 이를 측정하는 도구는 그만큼 발전하지 못했다. 2장에서는 GC의 명시적 비용을 조회하기 위한 JDK 26의 새로운 Java API를 소개한다.
OpenJDK에서 Serial GC는 고전적인 단일 코어 접근을 대표한다. 힙이 가득 차면, 컬렉터가 공간을 회수하는 동안 애플리케이션 실행은 완전히 중단된다. 그림 2가 보여주듯 이 메커니즘은 메모리 압박(memory pressure)을 일시정지 시간으로 효과적으로 변환한다.
컴퓨터 과학 101: 벽시계 시간(Wall-Clock) vs. CPU 시간
벽시계 시간은 실행에 소요된 경과 시간을 측정한다. CPU 시간은 CPU가 실제로 애플리케이션을 실행하느라 바빴던 총 시간을 정량화한다.
단일 스레드의 CPU 바운드 시나리오에서는 이 두 지표가 수렴한다. 반대로 멀티코어 환경에서는 서로 분리된다. 그 비율은 실행 중 평균적으로 사용된 코어 수를 근사한다. 이 구분은 성능 분석에 매우 중요하다. 반응성(responsiveness)과 효율(efficiency)을 분리해 주기 때문이다.
16GB 3.5GB
스레드
Main
t (s)
0 1.3 2.5 3.8 5 6.3 7.5 8.8 10
App: 9.0s (91.0%)GC Pause: 1.0s (10.0%)
App: 9.0s (90.0%)GC Pause: 1.0s (10.0%)
이처럼 눈에 보이는 비용은 산업계와 학계 전반에서 일시정지 시간에 대한 집착을 낳았다. 긴 일시정지는 매우 치명적이었기 때문에, 우리는 수십 년 동안 이를 없애기 위해 공학적 노력을 기울였다. 세대별 가설(generational hypothesis)을 활용해 객체를 나이로 분할하고 [2], 모든 일시정지 시간 스파이크에 대해 알림을 울리는 대시보드를 만들었다. 정의는 엄격했다: 애플리케이션 시간은 생산적이고, 일시정지 시간은 오버헤드다. 이 사고 모델은 개발자가 성능 비용을 배치 처리(batch processing) 방정식으로 이해하도록 해 주었다.
_배치 처리 사고 모델_은 근본적인 트레이드오프도 분명히 했다: 메모리는 처리량(throughput)을 산다. 힙을 키우면 JVM은 수집을 더 뒤로 미룰 수 있어 일시정지의 누적 비용이 줄어든다. 반대로 메모리를 제한하면 컬렉터가 더 자주 개입해야 하며, 애플리케이션을 겨우 유지하기 위해서도 CPU 사이클을 소모해야 한다(그림 3).
하지만 그림 3는 이 추상화가 깨지는 지점을 드러낸다. 첫째, 처리량은 일시정지 시간만으로 결정되지 않는다. GC 사이클에 진입할 때마다 세이프포인트(safepoint) 페널티가 발생한다—스레드를 멈추도록 동기화하는 데 드는 CPU 비용이다. 높은 빈도로 반복되면 이 행정 오버헤드가 누적되어 애플리케이션 실행에서 관측 가능한 오버헤드가 된다. 둘째, 일시정지 시간과 사용자 지연(latency) 사이의 매핑이 붕괴한다. GC 사이클 간격이 짧아질수록 애플리케이션의 함수(function)가 통계적으로 여러 번 중단될 가능성이 커진다. [3]가 지적했듯, 이렇게 지연이 복리로 누적되면 사용자 경험은 단일 정지의 지속 시간으로 더 이상 제한되지 않고, 중단의 연쇄 합계에 의해 좌우된다.
이를 실제 상황으로 상상해 보자. 바쁜 시간대에 요청을 처리하는 웹 서버가 있다. 메모리 압박이 크고 GC 사이클이 빈번하면, 짧은 일시정지도 누적될 수 있다. 단일 HTTP 요청이 GC 일시정지가 시작되기 직전에 들어왔고, 처리 중 다음 일시정지에 다시 끊길 수 있다. 이런 짧은 ‘버벅임’의 연쇄는 원래 매끄러워야 할 상호작용을 사용자에게 답답한 대기로 바꾼다. 갑자기 사용자 경험은 일시정지 시간 자체가 아니라, 이러한 세이프포인트 비용이 겹치며 만들어내는 예측 불가능한 총 중단량에 의해 제한된다.
4GB 3.5GB
스레드
Main
t (s)
0 2 4 6 8 10 12 14 16
App: 9.6s (61.0%)GC Pause: 6.4s (40.0%)
App: 9.6s (60.0%)GC Pause: 6.4s (40.0%)
멀티코어 CPU의 도래는 더 많은 작업자를 제공했고, 두 가지 근본적인 설계 옵션을 제시했다: 일시정지를 힘으로 밀어붙이기(병렬성, parallelism) 또는 애플리케이션과 나란히 실행하기(동시성, concurrency). 코어가 많아지면 더 나은 성능 잠재력이 생기지만, 애플리케이션 실행의 일부 구간에서 유휴 상태로 남는 코어는 _여전히 비용_을 유발한다. 특히 클라우드 환경에서는 프로비저닝된 리소스 기준으로 과금된다. 따라서 프로비저닝 비효율은 더 높은 운영 비용으로 직결된다. 조직은 추가 CPU가 다음 작업 폭주를 기다리며 소비하는 시간에 대해서도 비용을 지불하기 때문이다. 모든 코어를 효율적으로 활용하는 것은 기술적 관심사인 동시에 예산의 문제다.
Parallel GC는 병렬성을 사용해 일시정지 시간을 줄인다. 이는 본질적으로 Serial GC의 멀티 스레드 진화형이며, 기본적으로 사용 가능한 모든 코어를 활용해 일시정지 지속 시간을 최소화한다. 이를 통해 개발자는 GC가 CPU 사이클과 메모리를 어떻게 교환(trade)하는지 _병렬화된 배치 처리 사고 모델_로 추론할 수 있게 됐다.
그림 2의 단일 스레드 워크로드를 듀얼 코어 인스턴스에 재배치하고 Parallel GC를 사용한 경우를 그림 4에서 보자. 회수 작업을 두 코어에 분산함으로써 컬렉터는 일시정지 시간을 절반으로 줄이고, 그 결과 처리량이 순 5% 증가한다.
트레이드오프는 명시적이다: 하드웨어 병렬성을 활용해 stop-the-world 구간을 줄인다. 중요한 점은 GC의 총 CPU 시간은 일정하게 유지된다는 것이다. 작업이 제거된 것이 아니라 병렬화되었을 뿐이다. 하지만 이는 프로비저닝 비효율을 도입한다. 단일 스레드 애플리케이션 구간 동안 두 번째 코어는 유휴로 남아 있다가, 청소를 가속할 때만 사용된다.
16GB 4GB
스레드
Main
GC-0
GC-1
t (s)
0 1.2 2.4 3.6 4.8 5.9 7.1 8.3 9.5
App: 9.0s (95.6%)GC Pause: 513ms (5.4%)
App: 9.0s (89.8%)GC Pause: 1.0s (10.2%)
Parallel GC가 일시정지를 줄였지만, 그 일시정지 시간은 여전히 라이브 셋 크기와 암달의 법칙(Amdahl’s Law)에 의해 제한된다. 그림 5에 묘사된 암달의 법칙 [4]은 가속의 이론적 상한을 규정한다. 이상적인 세계(100% 병렬)에서는 20개 코어가 20배 가속을 산다. 하지만 직렬 실행이 단 1%(99% 병렬)만 도입돼도 화폐 가치가 떨어진다: 20개 코어는 17배만 제공한다. 64코어에서는 수익이 붕괴하여 39배에 그친다.
이를 하드웨어 인플레이션이라고 생각하라. 64코어에 비용을 지불하지만, 그 실리콘의 구매력은 거의 40%나 침식된다. 속도의 비용은 인플레이션처럼 증가하고, 화폐—추가 코어—는 실질적으로 가치가 없어질 때까지 평가절하된다. 결과적으로 GC 일시정지를 병렬화하는 것만으로는 막다른 길이다. 물리 법칙이 직렬 병목이 결국 지배할 것임을 말한다. 일시정지 시간 문제는 단지 하드웨어를 더 사는 것으로 해결될 수 없다.
일시정지 시간을 더 줄이기 위해, G1 [5]은(여러 전략 중 하나로) 일시정지 구간에서 수행하던 작업을 애플리케이션과 동시에 실행되도록, 즉 백그라운드로 옮긴다. 그림 6은 그 결과를 보여준다: 일시정지 지속 시간이 크게 줄어든다. 그러나 일시정지 동안의 CPU 사용량만 측정해 명시적 GC 비용을 추정한다면, 전체 비용을 놓치게 된다. 이 워크로드에서는 GC CPU 시간의 79%가 동시(concurrent) 단계에서 소비되며, 애플리케이션이 실행되는 동안 리소스를 소비했다.
Parallel GC가 병렬화된 배치 처리 모델에만 의존한다면, G1은 하이브리드다. 병렬화된 배치 처리와 백그라운드 작업을 결합한다. 이 분할 때문에 일시정지 시간이라는 지표는 GC의 명시적 비용을 불완전하게 측정한다. 이는 더 이상 그림 3처럼 ‘엣지 케이스’에서만 실패하는 것이 아니라, 수집기의 명시적 비용을 체계적으로 과소추정하게 된다.
16GB 4GB
스레드
Main
GC-0
GC-1
t (s)
0 1.2 2.4 3.6 4.8 5.9 7.1 8.3 9.5
App: 9.1s (81.0%)Concurrent GC: 1.4s (15.0%)GC Pause: 380ms (4.0%)
App: 9.1s (71.6%)Concurrent GC: 2.9s (22.4%)GC Pause: 760ms (6.0%)
그림 7이 보여주듯, ZGC는 객체 재배치를 포함한 거의 모든 무거운 작업을 동시(concurrent)로 수행하며, 힙 크기와 무관하게 서브 밀리초(sub-millisecond) 일시정지를 달성한다.
ZGC에서는 일시정지 지속 시간과 GC 오버헤드 사이의 상관관계가 사실상 분리된다. 작업이 사라진 것이 아니다. 백그라운드 스레드와 애플리케이션 스레드 자체(로드 배리어를 통해)에 분산(상각, amortize)된 것이다. 따라서 일시정지 시간에 의존해 ZGC의 비용을 정량화하는 것은 틀리다.
16GB 4GB
스레드
Main
GC-0
GC-1
t (s)
0 1.4 2.8 4.1 5.5 6.9 8.3 9.6 11
App: 11.0s (40.0%)Concurrent GC: 6.6s (60.0%)GC Pause: 1ms (0.0%)
App: 11.0s (45.4%)Concurrent GC: 13.2s (54.5%)GC Pause: 2ms (0.0%)
GC 일시정지 시간과 머신 리소스 사이의 상관관계는 세대가 바뀔 때마다 약해졌고, 그 결과 운영상의 사각지대가 생겼다. Parallel GC는 프로비저닝 비효율을 도입한다. 그림 4처럼 CPU 비용을 두 배로 지불해서 일시정지를 절반으로 줄이기 때문이다. 반면 G1은 백그라운드 스레드로 옮겨진 79%의 사이클을 무시함으로써 처리량 오버헤드를 숨긴다(그림 6). ZGC는 지표들을 사실상 완전히 분리한다. 서브 밀리초 지연은 더 이상 낮은 계산 노력을 의미하지 않는다.
Kanev 등 [6]이 지적했듯, 데이터센터에서는 상당한 비율의 CPU 사이클이 직렬화(serialization)와 메모리 할당 같은 저수준 작업에 쓰인다. 관리형 런타임에서 GC는 이러한 세금(tax)의 지배적인 원인이다. Hassanein [7]은 구글의 운영(프로덕션) 자바 플릿(지메일처럼 지연에 민감한 서비스를 구동)에서 이를 뒷받침하며, GC CPU 사용률이 상당한 하드웨어 및 전력 비용으로 직접 이어짐을 보여주었다.
결정적으로, 프로세스의 총 CPU 시간만 측정하는 것으로는 충분하지 않다. 표준 도구는 총 청구서(aggregate bill)는 포착하지만, _귀속(attribution)_을 제공하지 못한다. 계산 집약적 애플리케이션인지, 공격적인 JIT 컴파일러 때문인지, 또는 고전하는 GC 때문인지 구분할 수 없다. GC의 구체적 기여도를 분리하지 못하면 메모리 설정의 효율을 이해할 수 없다. 이는 성능을 디버깅하는 개발자에게도, 차세대 GC 알고리즘을 개발하려는 연구자에게도 마찬가지다. 수집기 작업에 대한 정확한 내부 회계가 필요하다.
이제 OpenJDK 26의 이야기로 넘어간다.
OpenJDK 26에서 나는 명시적 GC 비용을 정량화하는 두 가지 새로운 메커니즘을 도입했다: -Xlog:cpu를 통한 통합 로깅(로그는 JVM 종료 시 출력됨)과 Java API 메서드 MemoryMXBean.getTotalGcCpuTime()이다. 두 메커니즘의 기반에는 OpenJDK 내 어떤 GC 구현에도 지원을 제공하는 새로운 cpuTimeUsage.hpp 프레임워크가 있다.
성능 분석/벤치마크를 수행하는 연구자와 엔지니어는 아래에 보인 패턴을 구현해 이 새로운 텔레메트리를 추출할 수 있다. 반복(iteration) 단위로 GC 오버헤드를 측정하면 워크로드를 분리할 수 있으며, JVM 시작 중 발생하는 무관한 노이즈를 사실상 무시할 수 있다(물론 시작 지연이 분석 대상이라면 예외다). 아래는 이를 활용하는 예시다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import com.sun.management.OperatingSystemMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class Main {
static final MemoryMXBean memoryBean = ManagementFactory.getPlatformMXBean(MemoryMXBean.class);
static final OperatingSystemMXBean osBean = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class);
static void main(){
// Run 10 iterations to account for JIT warmup etc.
for (int i = 0; i < 10; i++) {
long start = System.nanoTime();
long startGC = memoryBean.getTotalGcCpuTime();
long startProcess = osBean.getProcessCpuTime();
try (var executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())) {
IntStream.range(0, 100000).forEach(_ -> {
App app = new App();
executor.submit(app::critical);
});
}
long end = System.nanoTime();
long endGC = memoryBean.getTotalGcCpuTime();
long endProcess = osBean.getProcessCpuTime();
long duration = end - start;
long gcCPU = endGC - startGC;
long processCPU = endProcess - startProcess;
System.out.println("GC used " + String.format("%.2f", 1.0 * gcCPU / duration) + " cores");
System.out.println("Process used " + String.format("%.2f", 1.0 * processCPU / duration) + " cores");
System.out.println("GC used " + (int)(100.0 * gcCPU / processCPU) + " % of total CPU spend");
System.out.println("---------------------------------");
}
}
}
class App {
byte[] a;
void critical() {
a = new byte[100000];
}
}
getTotalGcCpuTime과 getProcessCpuTime을 두 번 샘플링하면 델타(차이)를 얻을 수 있다. 이 델타의 비율(gcCPU / processCPU)은 총 CPU 시간 대비 명시적 GC 비용(%)을 제공한다.
짧게 실행되는 애플리케이션에서 CPU 시간 측정하기
JVM은 운영체제의 CPU 시간 회계(accounting)에 의존한다. 따라서 매우 짧게 실행되는 프로세스(예: 수 밀리초)의 경우 결과가 신뢰하기 어려울 수 있다.
이 지표를 맥락화하기 위해, DaCapo 벤치마크 스위트의 xalan 및 Spring 워크로드를 위에서 보여준 텔레메트리 패턴으로 계측했다. 평가는 Intel Xeon Gold 6354(18코어, 36 하드웨어 스레드, 39 MB LLC)에서 수행했으며, DaCapo의 기본 워크로드 프로비저닝(하드웨어 스레드당 애플리케이션 스레드 1개)을 적용했다. 뒤에서 분명해지겠지만, 두 애플리케이션 모두 36개의 하드웨어 스레드를 전부 포화(saturate)시키지 않는다. 더 작은 힙 크기에서의 프로세스 활용률은 스트레스가 큰 시스템의 반대—사용 중인 코어 수가 적음—를 나타낸다. 이는 GC가 임계 경로를 점유하고 있기 때문이다. 이런 상황에서 일시정지 시간은 역사적으로 GC 스트레스의 대리 지표 역할을 해왔지만, 이제야 비로소 진짜 계산 비용을 드러낼 수 있다.
그림 8은 xalan에서의 CPU-메모리 트레이드오프를 보여준다. 성능은 메모리 부족과 상관관계를 가진다. 39 MB에서 성능 절벽(cliff)이 나타나며, 엄청난 이득이 뒤따르지만 곧 빠르게 체감 수익(diminishing returns)으로 전환된다. 이 임계값을 넘어서면 암달의 법칙이 지배한다. 프로세스 CPU 사용량은 계속 증가하지만 처리량 개선은 미미하다. 보편적으로 “정답”인 GC CPU 오버헤드는 없다. (19 MB 힙에서 Parallel이 보여주는 것처럼) CPU의 79%를 GC에 쓰는 것이, 주요 제약이 메모리 풋프린트이고 부하 증가에 대한 탄력성이 낮아도 괜찮다면 완전히 수용 가능할 수 있다. 하지만 이제 그것은 조용한 운영 누수가 아니라, 의식적인 비즈니스 결정이 된다.
여기서 G1의 활용률은 비선형 관계를 따른다. 흥미롭게도 가장 작은 힙 크기에서 G1은 Parallel GC보다 65% 적은 CPU를 필요로 하면서도 동일한 처리량을 제공한다. ZGC는 이러한 제한된 힙 크기에서는 더 많은 기본 메모리 여유(headroom)를 요구하지만, 충분한 메모리가 주어지면 G1 및 Parallel과 동등한 수준(parity)에 도달한다. 이는 결함이 아니라 의도적인 설계 트레이드오프다. 우리는 애플리케이션 지연을 최소화하는 대신 메모리 풋프린트를 교환했다.
PGC
G1
ZGC
그림 9에서 Spring PetClinic 애플리케이션을 분석하면, 동학 변화가 나타난다. 202 MB 및 405 MB 힙 크기에서, G1은 처리량을 유지하기 위해 약 3.5배 더 많은 CPU를 소비한다—xalan에서 보였던 효율과는 극명한 대조다. ZGC는 힙 크기가 커질수록 다시 Parallel과 G1의 성능에 근접한다. 하지만 405 MB에서는 ZGC의 CPU 활용이 할당 스톨(allocation stall)의 “폭풍(storm)”에 의해 상한이 걸린다. 이는 동시 수집기에서 잘 알려진 안티패턴이다. headroom이 부족하면 재배치 작업을 선형화(linearization)해야 하고, 그 결과 애플리케이션 스레드가 멈춰 서게 된다.
PGC
G1
ZGC
너무 오랫동안 GC의 명시적 CPU 오버헤드를 이해하려면 침투적인 프로파일링, 커스텀 빌드, 또는 경험에 기반한 추정이 필요했다. OpenJDK 26에서 우리는 이 데이터를 더 널리(민주화하여) 사용할 수 있게 만들었다. MemoryMXBean.getTotalGcCpuTime()와 -Xlog:cpu의 도입은 명시적 GC 비용을 구체적이고 관측 가능한 지표로 노출한다.
학계와 엔지니어링 커뮤니티 모두가 이 표준 API를 채택하길 권한다.
연구자에게는, 오버헤드 보고를 위한 표준화된 기준선을 제공해 비교 연구의 노이즈를 줄여준다. 엔지니어에게는, 애플리케이션 힙을 튜닝하고 암달의 법칙의 벽에 부딪혔음을—소프트웨어 문제에 하드웨어를 더 던지기 전에—감지할 수 있는 관측 가능성(observability)을 제공한다.
도구는 이제 JDK 안에 있다. 이를 활용해 운영 시스템과 논문 모두에 엄격한 회계를 도입하자.
[1] S. M. Blackburn and A. L. Hosking, “Barriers: Friend or Foe?,” in ISMM, 2004.PDFDOI
[2] D. Ungar, “Generation Scavenging: A Non-Disruptive High Performance Storage Reclamation Algorithm,” in SDE 1, 1984.PDFDOI
[3] P. Cheng and G. E. Blelloch, “A Parallel, Real-Time Garbage Collector,” in PLDI, 2001.PDFDOI
[4] G. M. Amdahl, “Validity of the single processor approach to achieving large scale computing capabilities,” in AFIPS, 1967.PDFDOI
[5] D. Detlefs, C. Flood, S. Heller, and T. Printezis, “Garbage-first garbage collection,” in ISMM, 2004.PDFDOI
[6] S. Kanev, J. P. Darago, K. Hazelwood, P. Ranganathan, T. Moseley, G.-Y. Wei, and D. Brooks, “Profiling a warehouse-scale computer,” in ISCA, 2015.PDFDOI
[7] W. Hassanein, “Understanding and Improving JVM GC Work Stealing at the Data Center Scale,” in ISMM, 2016.PDFDOI