Common Lisp의 CLOS에서 구조체와 표준 객체의 슬롯 접근 비용 차이, 리더와 SLOT-VALUE, STANDARD-INSTANCE-ACCESS의 성능 특성, 그리고 여러 구현체에서의 벤치마크 결과를 간단히 살펴봅니다.
lisp로 태그됨
2026-05-27에 Daniel Kochmański가 작성
Common Lisp는 뛰어난 객체 시스템인 CLOS로 잘 알려져 있습니다. 그 구현에는 표준의 일부는 아니지만 프로그래머가 시스템의 기반 동작을 여러 흥미로운 방식으로 사용자화할 수 있게 해 주는 Metaobject Protocol이 함께하는 경우가 많습니다. 이러한 수준의 사용자화에는 비용이 따르며, 일부 CLOS 코드 경로는 표준 객체를 사용하지 않고 동일한 해법을 오픈 코딩한 경우에 비해 더 느릴 수 있습니다.
이 글의 목적은 슬롯 접근 측면에서 구조체 객체와 표준 객체의 차이에 대한 직관을 얻는 것입니다. 이제부터 구조체 객체는 구조체라고, 표준 객체는 인스턴스라고 부르겠습니다.
구조체는 메모리에서 튜플 (CLASS SLOTS)로, 인스턴스는 튜플 (CLASS STAMP SLOTS)로 표현된다고 상상할 수 있습니다. 구조체 클래스의 수정은 정의되지 않은 동작이지만, 인스턴스의 클래스는 바뀔 수 있습니다. 그래서 인스턴스는 자신이 최신 상태인지 아니면 구식인지 추적해야 합니다. 우리의 단순한 모형에서 그 정보는 클래스 세대를 나타내는 stamp로 표현됩니다.
인스턴스가 구식인지 추적하는 일은 중요합니다. 슬롯의 메모리 배치가 바뀔 수 있기 때문입니다. 슬롯은 삭제되거나, 추가되거나, 다른 위치로 이동할 수 있습니다. 이것은 장시간 실행되며 중단 시간이 없는 프로그램, 점진적 개발, 이미지 기반 워크플로에 편리합니다. 프로그램은 바뀌는 요구사항을 반영하기 위해 언제든 수정될 수 있고, 처음부터 다시 컴파일할 필요가 없습니다.
하지만 단점이 없는 것은 아닙니다. 구현체는 구조체 접근자가 절대로 바뀌지 않는다고 표준에 맞게 가정할 수 있고, 따라서 인라인될 수 있습니다. 이는 특히 구조체 접근이 단순한 메모리 참조라는 뜻입니다.
(declaim (inline structure-reader-a))
(defun structure-reader-a (object)
(svref (%slots object) 3))
반면 객체에서는 그렇게 가정할 수 없습니다. 적어도 객체가 구식이 아닌지 확인해야 하고, 리더가 더 일반적인 함수이기 때문입니다. 즉, 유연성이 한 단계 더 있습니다. 제네릭 함수를 인라인하는 일은 어렵습니다. 런타임에 새로운 메서드가 추가될 수 있고 유효 메서드가 바뀔 수 있기 때문입니다. 게다가 같은 리더 이름을 갖는 서로 다른 클래스가 있을 수 있으므로, 인스턴스에 올바른 클래스 배치를 사용하는 코드 조각도 포함해야 합니다.
이 때문에 인스턴스 리더 호출에는 다음이 포함됩니다:
이는 다음 의사코드로 예시할 수 있습니다. 여기서는 제네릭 함수의 다른 내부 요소는 무시합니다. 제네릭 함수의 구현에 따라 인스턴스가 구식이 아닐 때는 구식 인스턴스 검사도 피할 수 있습니다.
(declaim (notinline instance-reader-a))
(define-reader-function instance-reader-a (object)
(unless (%up-to-date-p object)
;; Among other things updates indexes for memory accesses.
;; This is a slow path.
(%recompile-reader-function #'instance-reader-a)
(return-from instance-reader-a (instance-reader-a object)))
(typecase object
(standard-class-a (svref (%slots object) 3))
(standard-class-b (svref (%slots object) 4))
(custom-class-c (slot-value object 'a))
(custom-class-d (slot-value object 'a))
(otherwise (no-applicable-method #'instance-reader-a object))))
이 모든 것은 표준 리더를 다룬다고 가정한 것입니다. Metaobject Protocol을 사용하면 슬롯 값을 어디에나 저장할 수 있으며, 특히 인스턴스와 함께 묶인 벡터가 아닌 곳에도 저장할 수 있고 추가 전처리를 넣을 수도 있습니다. 여기서 MOP를 깊게 다루지는 않겠습니다. 다만 표준 클래스의 표준 리더는 슬롯 벡터에 직접 접근할 수 있다는 점을 나타내기 위한 것입니다.
최소한, 단일 리더와 영리한 디스패치 알고리즘을 가정하면 다음과 같습니다:
(declaim (notinline instance-reader-a))
(define-reader-function instance-reader-a (object)
(if (eql (stamp object) 42)
(svref (%slots object) 3)
(if (%up-to-date-p object)
(no-applicable-method #'instance-reader-a object)
(progn
(%recompile-reader-function #'instance-reader-a)
(return-from instance-reader-a (instance-reader-a object))))))
다시 말해, 구조체 접근과 인스턴스 리더를 비교하는 것은 사과와 오렌지를 비교하는 것과 같습니다. 전자는 메모리 접근이고, 후자는 함수 호출이기 때문입니다.
SLOT-VALUE는 더 느릴 것입니다. 이 함수는 더 복잡한 SLOT-VALUE-USING-CLASS로 가는 트램펄린이기 때문이며, 이를 위해서는 다음이 필요합니다:
제네릭 함수 SLOT-VALUE-USING-CLASS는 위에서 정의한 리더와 비슷할 수 있지만, 디스패치할 인자가 더 많다는 점이 다릅니다. 따라서 디스패치 절차가 더 복잡할 수 있습니다. 어쨌든 이것은 적어도 위의 최적 리더 정의(표준 클래스용 단일 리더)만큼은 느립니다.
(defun slot-value (object slot-name)
(let* ((class (class-of object))
(slots (mop:class-slots class))
(slot (find slot-name slots :key #'mop:slot-definition-name)))
(mop:slot-value-using-class class object slot)))
Tim Bradshaw는 최근 블로그 글에서 인스턴스 슬롯 접근이 구조체 접근보다 약 38배 느리다고 주장했지만, 그는 인라인된 메모리 접근과 제네릭 함수 디스패치를 비교하고 있습니다. 공정한 비교라면 STANDARD-INSTANCE-ACCESS 연산자를 사용해야 합니다.
Metaobject Protocol은 STANDARD-INSTANCE-ACCESS 함수를 호출하여 제네릭 함수 디스패치에 따른 오버헤드 없이 인스턴스 슬롯에 접근하는 최적화된 방법을 정의합니다. 이 함수는 인라인될 수 있으며 구조체 객체 접근자와 비슷합니다. 가능한 정의는 다음과 같습니다:
(declare (inline mop:standard-instance-access))
(defun mop:standard-instance-access (object location)
(svref (%slots object) location))
인자 LOCATION은 기술적으로는 불투명한 객체이지만, 설명을 위해 여기서는 인덱스라고 가정하겠습니다(보통은 그렇습니다!). 그 값은 SLOT-DEFINITION-LOCATION 함수로 읽을 수 있습니다.
이제 벤치마크를 살펴봅시다! 각각 fixnum으로 초기화된 비형지정 슬롯 열 개를 담은 동등한 구조체와 인스턴스의 슬롯 접근 시간을 측정하겠습니다.
(defpackage "FAR-FROM-MOP"
(:import-from #+ccl "CCL"
#+ecl "MOP"
#+lispworks "CLOS"
#+sbcl "SB-MOP"
#-(or ccl ecl lispworks sbcl) "MOP"
"FINALIZE-INHERITANCE"
"CLASS-SLOTS"
"SLOT-DEFINITION-LOCATION"
"SLOT-DEFINITION-NAME"
"STANDARD-INSTANCE-ACCESS")
(:export "FINALIZE-INHERITANCE" "CLASS-SLOTS" "SLOT-DEFINITION-LOCATION"
"SLOT-DEFINITION-NAME" "STANDARD-INSTANCE-ACCESS"))
(defpackage "EU.TURTLEWARE.SLOT-BENCH"
(:use "CL")
(:local-nicknames ("MOP" "FAR-FROM-MOP")))
(in-package "EU.TURTLEWARE.SLOT-BENCH")
(eval-when (:compile-toplevel :load-toplevel :execute)
(defclass a ()
((a :initform (random 10) :reader a-a)
(b :initform (random 10) :reader a-b)
(c :initform (random 10) :reader a-c)
(d :initform (random 10) :reader a-d)
(e :initform (random 10) :reader a-e)
(f :initform (random 10) :reader a-f)
(g :initform (random 10) :reader a-g)
(h :initform (random 10) :reader a-h)
(i :initform (random 10) :reader a-i)
(j :initform (random 10) :reader a-j)))
(defstruct b
(a (random 10)) (b (random 10)) (c (random 10)) (d (random 10)) (e (random 10))
(f (random 10)) (g (random 10)) (h (random 10)) (i (random 10)) (j (random 10)))
(defparameter *o1* (make-instance 'a))
(defparameter *o2* (make-b))
(defparameter *locations*
(mapcar (lambda (slot-name)
(let ((class (find-class 'a)))
(mop:finalize-inheritance class)
(mop:slot-definition-location
(find slot-name (mop:class-slots class)
:key #'mop:slot-definition-name))))
'(a b c d e f g h i j))))
우리는 네 가지 슬롯 읽기 패턴을 측정할 것입니다:
SLOT-VALUE, MOP:STANDARD-INSTANCE-ACCESS또한, 가정된 메서드 캐시에 어느 정도 압력을 주기 위해 슬롯 접근을 무작위화하겠습니다. 매크로 expand-body는 연속된 접근 폼을 생성합니다:
(defmacro expand-body (type n-access)
(flet ((random-a () (nth (random 10) '(a-a a-b a-c a-d a-e a-f a-g a-h a-i a-j)))
(random-b () (nth (random 10) '(b-a b-b b-c b-d b-e b-f b-g b-h b-i b-j)))
(random-s () (nth (random 10) '(a b c d e f g h i j)))
(random-l () (nth (random 10) *locations*)))
(ecase type
(:reader
`(progn
,@(loop repeat n-access
for read = `(,(random-a) object)
collect `(incf count (the fixnum ,read)))))
(:slot-value
`(progn
,@(loop repeat n-access
for read = `(slot-value object ',(random-s))
collect `(incf count (the fixnum ,read)))))
(:instance-access
`(progn
,@(loop repeat n-access
for read = #+lispworks `(mop:fast-standard-instance-access object ',(random-l))
#-lispworks `(mop:standard-instance-access object ',(random-l))
collect `(incf count (the fixnum ,read)))))
(:structure-access
`(progn
,@(loop repeat n-access
for read = `(,(random-b) object)
collect `(incf count (the fixnum ,read))))))))
이제 우리의 "벤치마크 도구"와 테스트입니다. 계산 전후의 내부 실시간을 비교하는 단순한 측정입니다.
(defmacro do-bench (() &body body)
`(let ((now (get-internal-real-time))
(cnt (progn ,@body)))
(values (- (get-internal-real-time) now) cnt)))
(macrolet ((frob (name object access-type)
`(defun ,name (n &aux (object ,object))
(declare (fixnum n)
(optimize (speed 3) (safety 0) (debug 0)))
(do-bench ()
(let ((count 0))
(declare (fixnum count))
(dotimes (v n count)
(expand-body ,access-type 100)))))))
(frob test-object-v1 *o1* :reader)
(frob test-object-v2 *o1* :slot-value)
(frob test-object-v3 *o1* :instance-access)
(frob test-object-v4 *o2* :structure-access))
(defun test-batch (n)
(list (test-object-v1 n)
(test-object-v2 n)
(test-object-v3 n)
(test-object-v4 n)))
(defun do-benchmarks ()
(list* (list (lisp-implementation-type)
(lisp-implementation-version)
(machine-type)
internal-time-units-per-second)
(loop for e from 17 upto 26
for n = (expt 2 e)
collect (let (b)
(format t "... (expt 2 ~a):~%" e)
(setf b (test-batch n))
(format t "~a~%" b)
b))))
저는 이 테스트를 네 가지 구현체에서 실행했습니다. 아래 표는 최선의 결과와 비교한 접근 패턴의 비율을 보여 줍니다. 절대 시간은 포함하지 않았습니다.
| Implementation | reader / best | svalue / best | access / best | struct / best |
|---|---|---|---|---|
| CCL 1.12.2 | 17 | 12 | 2 | 1 |
| ECL 26.5.5 | 616 | 719 | 1 | 175 |
| LispWorks 8.1.2 | 22 | 79 | 1 | 1 |
| SBCL 2.4.2 | 10 | 9 | 1 | 1 |
결론:
제네릭 함수를 사용해 슬롯에 접근하는 것은 실제로 단일 메모리 접근보다 느립니다. 이는 이러한 함수를 인라인할 수 없고, 매우 많은 가능성을 처리해야 하기 때문입니다. 특히 서로 다른 클래스의 인자를 디스패치해야 하고, 인스턴스 클래스와 리더 제네릭 함수 자체의 재정의도 고려해야 합니다. 이 모든 비용의 대가로 우리는 프로그램의 확장성과 런타임 유연성을 얻게 됩니다.
리더는 특정 상황에서는 SLOT-VALUE보다 더 잘 최적화될 수 있습니다. 다른 함수를 한 번 더 거치고 클래스 슬롯 정의에 접근할 필요가 없기 때문입니다. CCL과 SBCL은 이 최적화 기회를 활용하지 않습니다.
인스턴스 메모리 접근과 구조체 메모리 접근 시간은 SBCL과 LispWorks에서는 대체로 비슷하지만, CCL에서는 인스턴스 접근이 두 배 느립니다. ECL은 어떤 이유에서인지 구조체 리더를 인라인하지 않는 특이한 동작을 합니다. 이는 더 조사해 볼 필요가 있지만, 어쨌든 인스턴스 접근이 175배 더 빠르네요 ;-)!
참고:
외부 의존성을 피하기 위해 매우 기본적인 시간 측정을 직접 정의했고, 몇몇 구현체에서 직접 정의한 MOP 연산자를 사용했습니다. 더 완전한 해법으로는 Yukari Hafner의 "trivial-benchmark"와 Pascal Costanza의 "closer-mop"를 살펴보세요.
Lispworks의 CLOS::STANDARD-INSTANCE-ACCESS는 MOP 명세를 따르지 않으며 슬롯 위치를 주면 오류를 냅니다(슬롯 이름을 기대합니다). 이것은 인스턴스 접근 성능에 심각한 영향을 줍니다. 올바르게 호출해야 할 함수는, 이상하게도, CLOS::FAST-STANDARD-INSTANCE-ACCESS입니다.
ECL 성능은 비교상 좋지 않지만, 좋은 소식이 있습니다! 저는 Fast Generic Function Dispatch 알고리즘을 구현 중이며, 더 나아질 것입니다.
흥미로운 점으로, 일부 구현체는 slot-value-using-class와 다른 CLOS 프로토콜을 구조체 클래스에도 특수화합니다.