Lowent 매뉴얼←↑→

41 경계의 증명 — 구간, 관계, 행우선 주소

먼저 알아야 할 것

9장 줄 · 계약으로 길이 조건을 적으면 색인 검사가 사라진다
14장 계약 · 강제되는 계약은 사실이 되어 검사를 지운다
39장 수학 도구상자 · 추상해석은 넓게 틀려야 하고, 반복 분석은 고정점에서 멈춘다
40장 수의 증명 · radd_no_check · idx_no_check

돌아보기

40장에서 검사를 지우는 최적화의 정당성이라고 한 두 정리는 무엇이었고, 각각 무엇을 전제로 했는가?

답. radd_no_check 는 결과 구간이 선언 타입 안에 들면 넘침 검사가 필요 없다고, idx_no_check 는 색인이 0 이상이고 길이보다 작음이 알려지면 경계 검사가 필요 없다고 했다. 둘 다 그 사실을 이미 안다는 것이 전제다. 이 장은 컴파일러가 그 사실을 어떻게 얻는지 — 그리고 얻었다고 잘못 믿는 일을 무엇이 막는지 — 를 다룬다.

이 장의 필요성과 맥락

이 언어는 정수 연산이 넘치면 멈추고, 색인이 범위를 벗어나도 멈춘다. 그러면 연산마다 검사가 붙어 느려질 것 같다. 실제로는 대부분의 검사가 번역 시각에 사라진다. 넘칠 수 없음, 범위 안임이 증명되기 때문이다. C 는 확인하지 않아 빠르지만 범위를 벗어나면 남의 메모리를 읽는다 — 보안 사고의 절반이 거기서 나온다. Lowent 는 셋째 길을 간다. 확인을 하되, 확인할 필요가 없음이 증명되면 그 자리에서 지운다. “정직하게 적으면 빨라진다” 는 주장이 전부 이 장의 분석에 기대므로, 그 분석이 어디서 틀릴 수 있고 무엇이 그것을 받치는지까지 본다.

이 장이 끝나면

구간 분석이 값의 범위를 계산하는 법과 그 건전성이 한 방향이라는 것을 확인한다. 구간이 못 하는 변수 사이의 사실을 한 칸짜리 관계로 나르는 법, 경계 검사가 사라지는 네 단계(직접 · 지역에 담은 길이 · 계약의 용량 · 행우선 주소)를 --emit-proof 출력으로 읽는다. 행우선 증명이 무너지는 조건, 분석이 틀렸을 때 VM 이 컴파일러를 고발하는 장치, 지운 검사마다 남는 증명서와 그 검산기, 그리고 계약을 붙여도 남는 검사를 보게 된다.

이 장에서 답할 질문

  1. lru_idx 처럼 두 값이 합류한 색인은 왜 검사가 남는가?

41.1 구간으로 계산한다#

컴파일러는 값 하나 대신 구간 [lo, hi] 로 계산해 넘침과 경계를 판정한다.

a ∈ [0, 100],  b ∈ [0, 100]
a + b ∈ [0, 200]        lo+lo, hi+hi
a − b ∈ [−100, 100]     lo−hi, hi−lo   뺄셈은 뒤집힌다 --- 흔한 실수 자리
a × b ∈ [0, 10000]      부호가 섞이면 네 곱의 최소·최대

examples/ch41/sum_contract.low

module sum_contract .
rem run: small_sum 100 100
rem ir

fn small_sum input a u8 . input b u8 . output u16 .
  requires le a 100 .
  requires le b 100 .
do
  return add (widen u16 a) (widen u16 b) .
end

실행 결과

$ lowentc --run small_sum sum_contract.low 100 100
small_sum(100, 100) = 200
$ lowentc --ir sum_contract.low
-- runtime checks (interval analysis: overflow · division · narrowing) --
   2 / 2 removed  (100%)

계약이 두 입력을 100 이하로 묶었으므로 합은 200 을 넘지 않고 u16 에서 넘치지 않는다. 검사가 모두 사라진다. 같은 모양이라도 입력이 묶이지 않으면 검사가 남는다.

examples/ch41/product_bare.low

module product_bare .
rem run: product 1000 1000
rem ir

fn product input a u64 . input b u64 . output u64 .
do
  return mul a b .
end

실행 결과

$ lowentc --run product product_bare.low 1000 1000
product(1000, 1000) = 1000000
$ lowentc --ir product_bare.low
-- runtime checks (interval analysis: overflow · division · narrowing) --
   0 / 1 removed  (0%)

1000 × 1000 은 넘치지 않지만 분석은 a·b 가 어디까지 커질지 모른다. 그래서 검사를 남기고, 실제 값이 들어와서야 넘치지 않음을 확인한다.

정리들은 모두 한 방향이다. “안전하다고 말한 자리는 정말 안전하다.” 그 반대 — 안전한데 안전하다고 말하지 못함 — 는 정리가 보장하지 않는다. 그것은 성능 손해일 뿐이다.

분석이 넓게 잡음  →  검사가 남는다     →  느리다(안전)
분석이 좁게 잡음  →  검사가 사라진다   →  틀린 메모리 접근(위험)

그래서 규칙을 하나 더할 때마다 묻는 것은 늘 같다 — “이 규칙이 실제보다 좁게 잡을 수 있는가?”

41.2 관계 — 구간이 못 하는 것#

구간은 변수를 따로따로 본다. i ∈ [0, 100], n ∈ [0, 100] 을 알아도 i < n 인지는 모른다. 그런데 배열 안전에 필요한 사실이 바로 그것이다. 그래서 구간 옆에 한 칸짜리 관계를 둔다.

lenlt[i] = s     지역 i 는 len(지역 s) 보다 작다
lerel[i] = n     지역 i 는 지역 n 보다 작다

이 사실은 분기 조건에서 온다. while lt i n 의 몸 안에서는 i < n 이 참이고, 조건이 거짓인 갈래에서는 i ≥ n 이 참이다. 무거운 관계 영역 (팔각형·다면체) 대신 한 칸을 두는 까닭은 실제 코드의 모양 때문이다. 배열을 훑는 코드는 거의 언제나 while lt i (len s) 모양이고, 정교한 이론보다 실제 코드의 모양을 정확히 담는 쪽이 값이 크다.

사실을 다루는 규칙은 세 조각이다.

  1. 얻기. 분기 조건이 참인 갈래에 그 조건을 사실로 기록해 둔다.
  2. 나르기. 대입을 따라간다 — var x be j 에서 j < cap 이면 x < cap 이다. 합류에서는 두 경로가 같을 때만 살린다. 한쪽에서만 참인 것은 사실이 아니다.
  3. 죽이기. 관련 변수가 바뀌면 그 사실을 양방향으로 죽인다. 슬라이스가 다시 묶이면 그 길이에 대한 사실이 전부 죽는다.

셋째가 안전의 핵이다. 사실을 오래 살려 두는 것이 곧 잘못된 검사 제거이므로, 죽이는 쪽이 늘 더 공격적이다.

41.3 네 단계#

경계 검사가 사라지는 모양을 세지는 순서로 늘어놓는다. --emit-proof 는 증명으로 지운 검사를 한 줄씩 적는다 — op 이름, 명령 위치, 연산, 쓴 규칙, 그리고 그 규칙이 쓴 범위다.

examples/ch41/bound_stages.low

module bound_stages .
rem run: direct [1,2,3]
rem run: stored [1,2,3]
rem run: capacity [1,2,3,4] 3
rem run: grid [1,2,3,4] 2
rem proof

rem 1 --- 반복 조건이 len 을 직접 본다
fn direct input s slice u8 . output u64 . do
  var t u64 be 0 .
  var i u64 be 0 .
  while lt i (len s) . do
    set t (wrap_add t (widen u64 (index s i))) .
    set i (add i 1) .
  end
  return t .
end

rem 2 --- 길이를 지역에 담았다
fn stored input s slice u8 . output u64 . do
  let n u64 be len s .
  var t u64 be 0 .
  var i u64 be 0 .
  while lt i n . do
    set t (wrap_add t (widen u64 (index s i))) .
    set i (add i 1) .
  end
  return t .
end

rem 3 --- 계약이 용량을 말한다
fn capacity input s slice u8 . input cap u64 . output u64 .
  requires ge (len s) cap .
do
  var t u64 be 0 .
  var j u64 be 0 .
  while lt j cap . do
    set t (wrap_add t (widen u64 (index s j))) .
    set j (add j 1) .
  end
  return t .
end

rem 4 --- 행우선 주소 i·n + k
fn grid input a slice u8 . input n u64 . output u64 .
  requires le n 1000 .
  requires ge (len a) (mul n n) .
do
  var t u64 be 0 .
  var i u64 be 0 .
  while lt i n . do
    var k u64 be 0 .
    while lt k n . do
      set t (wrap_add t (widen u64 (index a (add (mul i n) k)))) .
      set k (add k 1) .
    end
    set i (add i 1) .
  end
  return t .
end

실행 결과

$ lowentc --run direct bound_stages.low [1,2,3]
direct([1,2,3]) = 6
  arg0 (written) = [1,2,3]
$ lowentc --run stored bound_stages.low [1,2,3]
stored([1,2,3]) = 6
  arg0 (written) = [1,2,3]
$ lowentc --run capacity bound_stages.low [1,2,3,4] 3
capacity([1,2,3,4], 3) = 6
  arg0 (written) = [1,2,3,4]
$ lowentc --run grid bound_stages.low [1,2,3,4] 2
grid([1,2,3,4], 2) = 10
  arg0 (written) = [1,2,3,4]
$ lowentc --emit-proof bound_stages.low
# proven 12 certified 12
direct 12 index R-IDX-LENLT 0 281474976710655
direct 18 add.i64 R-ADD-LENLT 0 281474976710655 1 1 64 0
stored 14 index R-IDX-LENLT 0 281474976710655
stored 20 add.i64 R-ADD-LEREL 0 281474976710655 1 1 64 0
capacity 16 index R-IDX-LENLT 0 281474976710655
capacity 22 add.i64 R-ADD-LEREL 0 281474976710655 1 1 64 0
grid 8 mul.i64 R-MUL-CAP 0 1000 0 1000 64 0
grid 29 mul.i64 R-MUL-CAP 0 999 0 1000 64 0
grid 31 add.i64 R-ROW-CAP 0 999000 0 999 64 0
grid 32 index R-IDX-ROWMAJOR 0 999999
grid 38 add.i64 R-ADD-LEREL 0 999 1 1 64 0
grid 43 add.i64 R-ADD-LEREL 0 999 1 1 64 0

i 와 j 를 하나 올리는 덧셈이 R-ADD-LENLT·R-ADD-LEREL 로 함께 사라진 것도 같은 관계 덕분이다. i < n 이면 i + 1 ≤ n 이라 넘치지 않는다.

41.4 행우선 증명#

수학. 행우선 주소가 범위 안이라는 증명

전제는 셋이다 — 계약 len a ≥ p·q, 바깥 반복 조건 i < p, 안쪽 반복 조건 k < q. 주소는 멈추는 곱과 멈추는 합으로 계산한 i·q + k 다. 그러면 i ≤ p − 1 이므로 i·q ≤ (p − 1)·q = p·q − q 이고, k ≤ q − 1 이므로 i·q + k ≤ p·q − q + q − 1 = p·q − 1 이다. 따라서 주소 ≤ p·q − 1 < p·q ≤ len a 다. 부등식 두 개를 더했을 뿐이다.

고등학교 수학이지만, 전제가 하나라도 빠지면 무너진다.

무엇을 뺐나색인 검사
전부 있음사라진다
계약 len a ≥ n·n 을 뺀다남는다
i 를 다른 변수로 묶는다(while lt i m)남는다
주소를 wrap_mul 로 계산한다남는다

표 41.1 — 행우선 규칙이 서는 조건

마지막 줄이 가장 미묘하다.

examples/ch41/grid_wrap.low

module grid_wrap .
rem run: grid [1,2,3,4] 2
rem proof

rem 같은 주소를 감기는 곱으로 계산했다
fn grid input a slice u8 . input n u64 . output u64 .
  requires le n 1000 .
  requires ge (len a) (mul n n) .
do
  var t u64 be 0 .
  var i u64 be 0 .
  while lt i n . do
    var k u64 be 0 .
    while lt k n . do
      set t (wrap_add t (widen u64 (index a (wrap_add (wrap_mul i n) k)))) .
      set k (add k 1) .
    end
    set i (add i 1) .
  end
  return t .
end

실행 결과

$ lowentc --run grid grid_wrap.low [1,2,3,4] 2
grid([1,2,3,4], 2) = 10
  arg0 (written) = [1,2,3,4]
$ lowentc --emit-proof grid_wrap.low
# proven 5 certified 5
grid 8 mul.i64 R-MUL-CAP 0 1000 0 1000 64 0
grid 29 mul.i64 R-ARITH-RANGE 0 999 0 1000 64 0
grid 31 add.i64 R-ARITH-RANGE 0 999000 0 999 64 0
grid 38 add.i64 R-ADD-LEREL 0 999 1 1 64 0
grid 43 add.i64 R-ADD-LEREL 0 999 1 1 64 0

같은 수식인데 index 줄이 없다. wrap_mul 은 넘칠 때 조용히 감긴다. 감긴 값은 작아질 수 있어서 i·q ≤ (p − 1)·q 라는 단계가 깨진다. 이 예제에서는 n ≤ 1000 이라 실제로 감기지 않지만, 규칙은 “멈추는 곱일 때” 에만 선다. 멈추는 연산이 아니면 사실이 아니다.

그러면 계약의 곱 p·q 자체가 넘치면? 계약의 곱은 멈추는 곱이고 계약 검사는 절대 지워지지 않으므로, 본문에 닿았다면 넘치지 않았다. 경계 검사를 없앤 자리의 유일한 방벽이 계약 검사다. 그래서 계약 검사는 모드로도 최적화로도 지워지지 않는다.

개발 저장소의 측정에서 이 규칙은 행렬 곱셈 벤치마크의 경계 검사를 6 개에서 1 개로 줄였고(남은 하나는 n ≥ 1 이 계약에 없는 index c 0 자리다), 계약이 용량을 말하는 LRU 벤치마크는 10 개에서 4 개로 줄였다.

흔한 오해. 검사를 줄인 만큼 빨라진다

검사 개수는 비용의 대리 지표가 아니다. 한 번도 걸리지 않아 분기 예측이 늘 맞는 검사는 사실상 공짜여서, 검사를 7 개에서 0 개로 줄였는데 시간이 그대로인 벤치마크가 있었다. 검사가 비싼 것은 행렬 곱셈처럼 자동 벡터화를 막는 자리다. 그래서 이 부는 개수를 줄인 것을 성과로 세지 않는다. 그리고 --ir 이 세는 산술 검사는 증명이 기록될 뿐 C 백엔드가 여전히 멈추는 호출을 낸다 — 그 수는 증명된 것이지 사라진 것이 아니다.

41.5 증명을 신뢰하되, 신뢰를 검사로 받친다#

정리는 규칙이 옳다고 말하지만, 컴파일러가 그 규칙을 정확히 적용했는지는 따로 확인해야 한다. 장치가 둘이다.

분석 자기 고발. VM 은 검사를 지우지 않는다. 컴파일러가 “지워도 된다” 고 표시한 자리를 실행해 보고, 실제로 범위를 벗어나면 그 자리에서 E-VM-ANALYSIS: … the interval/relational analysis is UNSOUND (this is a compiler bug) 로 컴파일러를 고발한다. 네이티브 빌드에는 검사가 없어 빠르고, VM 에는 검사가 있어 표시가 거짓이면 알려 주며, 두 백엔드 대조가 둘을 같은 입력으로 돌린다. 이 장치가 실제로 일한 적이 있다. 행우선 규칙을 넣으면서 requires ge (len a) (mul n n) 을 검사되는 계약으로 믿었는데, 처리기가 그 모양의 계약을 읽지 못해 실제로는 검사가 없었고, 바로 이 진단이 떴다. 믿을 것은 검사되는 것뿐이다.

증명서 검산. --emit-proof 의 줄 끝 숫자가 그 근거다. 컴파일러는 검사를 지운 자리마다 산술 근거(Farkas 증명서)를 남기고, 컴파일러와 따로 짠 검산기가 그 산술을 다시 계산한다. 첫 줄의 certified 수가 검산을 통과한 수다. 검산기 가운데 하나는 규칙의 건전성을 증명한 Coq 코드에서 OCaml 로 추출한 것이다(LowentCert.v·LowentCertExtract.v). 사람이 옮긴 검산기는 “이 산술을 옳게 옮겼나” 를 아무도 확인하지 않지만, 추출본은 정리가 말하는 함수 그 자체다. 다만 이것은 규칙의 산술을 믿을 만하게 할 뿐, “그 자리에 그 사실이 정말 성립하는가” 는 검산기의 관할 밖이다.

실제 사례. 멈추지 않는 도구

구간 분석의 예제를 만들다가, 입력 n 만큼 도는 반복이 든 op 에서 --check 는 곧 끝나는데 --ir 이 60 초를 넘겨도 끝나지 않는 일이 있었다. --ir 은 계약에서 경계값을 뽑아 op 을 실제로 실행해 보는데, n = 2^64 − 1 이면 그 반복은 사람의 수명 안에 끝나지 않는다. 그 실행에 걸음 예산을 두고, 예산에 걸린 경우는 건너뛰었다고 센다. 조용히 통과시키지 않는 것이 요점이다. 검사하지 않은 것을 검사했다고 말하는 순간 그 도구는 거짓말이 된다. 사용자가 돌리는 --run 에는 예산이 없다 — 끊어도 되는 것은 도구가 스스로 만든 시험뿐이다.

문. lru_idx 처럼 두 값이 합류한 색인은 왜 검사가 남는가?

답. 합류에서는 두 경로가 같을 때만 사실이 산다. lru_idx 가 한쪽에서는 0, 다른 쪽에서는 j 로 들어오면, j < cap 이라는 사실은 한쪽 경로의 것 이라 합류에서 죽는다. 0 < cap 을 따로 알더라도 한 칸 관계는 “둘 다 cap 보다 작다” 를 모아 나르지 않는다. 사실을 너무 오래 살려 두는 쪽으로 틀리는 것보다는 검사를 남기는 쪽으로 틀리는 것이 이 분석의 방향이다.

41.6 증명하지 않은 것#

계약을 붙여도 빠지지 않는 검사가 남는다. 측정한 자리들이다.

남은 자리왜 못 지우나
행렬 곱셈의 index c 0n ≥ 1 이 계약에 없다(더하면 닫힌다)
LRU 의 index keys lru_idx0 과 j 가 합류한 값이라 관계 사실이 합류에서 죽는다
체의 index s i(while lt (mul i i) n)i·i < n ⟹ i < n 은 또 다른 비선형 모양이고, 실제 코드에 드물어 규칙을 만들지 않았다
정렬의 파티션 색인hi ≤ len s 를 비엄격으로 나르는 자리가 없다(지금 관계는 엄격 전용이다)

표 41.2 — 남는 검사와 그 까닭

복습 정리

구간 분석은 값 대신 범위로 계산하고, 넓게 틀리면 느릴 뿐 좁게 틀리면 위험하다. 한 칸짜리 관계가 분기 조건에서 i < len s 같은 사실을 얻어 나르고, 관련 변수가 바뀌면 공격적으로 죽인다. 경계 검사는 직접 · 지역에 담은 길이 · 계약의 용량 · 행우선 주소의 네 단계에서 사라지고, --emit-proof 가 그 규칙을 한 줄씩 적는다. 행우선 증명은 멈추는 곱일 때만 서고, 계약 검사는 절대 지워지지 않는다. VM 의 자기 고발과 Coq 에서 추출한 증명서 검산기가 신뢰를 받치며, 합류·비선형·배열 내용 때문에 남는 검사는 남는다고 적는다.