剛好看到一些跟編譯器相關的議題,所以來紀錄一下。

在一些語言中會有行內函式(inline function)的設計,使用的話一般會讓程式的效能變好。

最知名應該是 C 跟 C++ 的 inline

Inline function 會在編譯時期直接將函式內容展開到程式碼中,不過展開與否是由編譯器決定的,inline 的標記只是告訴編譯器這個函式可以成為 inline function。

Inline expansion 就是編譯時期會由編譯器執行的一個動作,看起來與 macro expansion 相似,但不同的是 macro expansion 是在前處理(preprocessing)時期做的,會直接展開在原始碼裡頭,而 inline expansion 則是在編譯時期做的,會在呼叫位點(call site)直接展開。

展開後編譯器便可以進行最佳化,執行時,就不需要做函式呼叫,也不會在 function stack 上多配置空間。一般使用在短小的函式上會有好處,在巨大的函式上使用不一定會有好處。然而過多的 inline function 反而可能造成過多的指令快取的消耗,造成反效果。

在 Julia 中,編譯器會自動偵測哪些函式可以被展開,會自動做 inline expansion。一般短小的函式會自動被編譯器判定要 inline,不過也可以由程式設計師自己指定哪些巨大函式可以 inline,可以參考[文件]](https://docs.julialang.org/en/v1.2/base/base/#Base.@inline)。

除了 @inline 以外,還有 @noinline。為了避免過多的 inline 反而傷害效能,也可以標記一些短小的函式不要 inline。

範例:

1
2
3
@inline function bigfunc(x)
...
end

Julia 什麼時候 inline expansion?

我們來實驗看看,以下有兩個函式,foobar。其中讓 foo 為 inline。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
julia> @inline foo(x) = 3x
foo (generic function with 1 method)

julia> bar(x) = foo(x)^2
bar (generic function with 1 method)

julia> bar(5)
225

julia> @code_lowered bar(5)
CodeInfo(
1 ─ %1 = Main.foo(x)
│ %2 = Core.apply_type(Base.Val, 2)
│ %3 = (%2)()
│ %4 = Base.literal_pow(Main.:^, %1, %3)
└── return %4
)

julia> @code_typed bar(5)
CodeInfo(
1 ─ %1 = Base.mul_int(3, x)::Int64
│ %2 = Base.mul_int(%1, %1)::Int64
└── return %2
) => Int64

你會發現在 lower 的階段仍保留有 foo 的函式呼叫的過程,但是到了 typed 的階段就已經剩下計算的部份了。

所以其實在進入 LLVMIR 以前就已經先做完 inline expansion 了。

相關技術:Inline caching

留言與分享

之前介紹過將值放在參數型別上。

今天來介紹一下如何可以做到類似運算的效果。

1
2
3
4
5
struct A{T}
end

a1 = A{5}()
a2 = A{3}()

在一些應用場景上會希望將參數欄位上的值做運算,例如加總。

這時候我們可以這樣做:

1
2
3
4
5
6
import Base:+

function +(::A{T}, ::A{S}) where {T, S}
x = T + S
return A{x}()
end

如此一來,就可以簡單搞定囉!

1
println(a1 + a2)
1
A{8}()

留言與分享

我在使用的時候有注意到在參數化型別中使用值的方式與傳統封裝的方式有效能上的差異。

所以我就做了一些測試。

在參數化型別中使用值:

1
2
3
struct A{T}

end

傳統型別封裝:

1
2
3
struct B
x::Int64
end

全文出現的程式碼為實際測試程式碼

因為 Julia 有提供好用的 @code_llvm@code_native 來觀察一行程式碼實際被轉換成 LLVM 或是組合語言的時候會產生多少行的程式碼,藉此我們可以用低階程式碼來評估是否有效率。程式碼的行數愈少是越有效率的。

建立

我們來測試一個物件被建立需要多少行的程式碼。

A - LLVM

1
@code_llvm A{5}()
1
2
3
4
5
6
7
;  @ REPL[1]:3 within `Type'
define nonnull %jl_value_t addrspace(10)* @japi1_Type_12238(%jl_value_t addrspace(10)*, %jl_value_t addrspace(10)**, i32) #0 {
top:
%3 = alloca %jl_value_t addrspace(10)**, align 8
store volatile %jl_value_t addrspace(10)** %1, %jl_value_t addrspace(10)*** %3, align 8
ret %jl_value_t addrspace(10)* addrspacecast (%jl_value_t* inttoptr (i64 140407726014496 to %jl_value_t*) to %jl_value_t addrspace(10)*)
}

B - LLVM

1
@code_llvm B(5)
1
2
3
4
5
6
;  @ REPL[1]:2 within `Type'
define { i64 } @julia_Type_12221(%jl_value_t addrspace(10)*, i64) {
top:
%.fca.0.insert = insertvalue { i64 } undef, i64 %1, 0
ret { i64 } %.fca.0.insert
}

A - Assembly

1
@code_native A{5}()
1
2
3
4
5
; ┌ @ REPL[1]:3 within `Type'
movq %rsi, -8(%rsp)
movabsq $140407726014496, %rax # imm = 0x7FB338A20020
retq
; └

B - Assembly

1
@code_native B(5)
1
2
3
4
5
; ┌ @ REPL[1]:2 within `Type'
movq %rsi, %rax
retq
nopw %cs:(%rax,%rax)
; └

取值

接著測試從物件當中取值出來的效能。

定義取值的方法:

1
2
get_value(::A{T}) where {T} = T
get_value(b::B) = b.x

事先建立好物件:

1
2
a = A{5}()
b = B(5)

A - LLVM

1
@code_llvm get_value(a)
1
2
3
4
5
;  @ REPL[8]:1 within `get_value'
define i64 @julia_get_value_12274() {
top:
ret i64 5
}

B - LLVM

1
@code_llvm get_value(b)
1
2
3
4
5
6
7
8
9
;  @ REPL[5]:1 within `get_value'
define i64 @julia_get_value_12630({ i64 } addrspace(11)* nocapture nonnull readonly dereferenceable(8)) {
top:
; ┌ @ sysimg.jl:18 within `getproperty'
%1 = getelementptr inbounds { i64 }, { i64 } addrspace(11)* %0, i64 0, i32 0
; └
%2 = load i64, i64 addrspace(11)* %1, align 8
ret i64 %2
}

A - Assembly

1
@code_native get_value(a)
1
2
3
4
5
; ┌ @ REPL[8]:1 within `get_value'
movl $5, %eax
retq
nopw %cs:(%rax,%rax)
; └

B - Assembly

1
@code_native get_value(b)
1
2
3
4
5
; ┌ @ REPL[5]:1 within `get_value'
movq (%rdi), %rax
retq
nopw %cs:(%rax,%rax)
; └

給大家參考。

留言與分享

應該不少人看到這個標題會摸不著頭緒到底要做什麼,但是看完 Julia 中常見的程式碼你就會了解了。

1
Array{Any, 2}

有沒有曾經納悶過那個數字 2 到底是怎麼進到參數的位置上的呢?

參數的位置不是只能放型別(type)嗎?

這同時也是我困惑已久的問題,就搜尋了一下,果不其然被我找到了方法:

1
2
struct A{T}
end
1
A{5}()

原來這麼簡單就可以完成了!語法上並沒有限定一定要是型別,要放型別以外的東西似乎是可以的。

我目前測試了可以的有:Int64、Float64、Complex、Char、Bool、Symbol,所以估計數字應該都是可以的。

不行的有:String、Array,估計物件或是陣列都是不行的。

定義範圍

不過使用上並沒有任何限制會有點危險,所以還是定義一下範圍會比較好,像是:

1
2
3
4
5
6
struct A{I}
function A{I}() where {I}
isa(I,Integer) || error("bad parameter")
new()
end
end

這樣就可以限制參數要是整數的範圍。

從參數取值

那我們能不能從型別的參數當中取值呢?

可以。

1
get_value(::A{I}) where A{I} = I

如此一來,我們就可以從型別中拿到值了。

好處?

這麼做有什麼好處?

當你把值的資訊放到型別當中,型別就多了一些資訊可以提供編譯器處理,這對於要自己設計型別階層可是非常好用的。

例如像是你可以將陣列的長度資訊儲存到型別上,這樣編譯器就可以處理陣列的長度資訊了。

這樣的程式風格會跟 dependent type language 有些相似了。

大家可以玩玩看。

留言與分享

Julia 的物件導向

今天特別要來談談Julia這個語言的物件導向模型。

我已經聽到有些人跟我說「Julia才沒有物件導向」或是「我不承認這是物件導向」等等這類的話。

不過我得要跟大家說,物件導向不是只有大家看到的C++或是Java那個樣子的物件導向模型。

介紹 Julia 的物件導向模型

如果你的背景是C++, Java, Python, Ruby等等語言的話,那你應該非常熟悉物件導向的幾個要素:

  1. 封裝
  2. 繼承
  3. 多型

談到封裝,Julia幾乎沒有封裝的概念。

是的,Julia不封裝!

Julia宣告的是型別,不是類別;型別他只宣告了有哪些欄位,他也不會把方法宣告在型別裡頭。

所以Julia的型別感覺上會比較像是C的struct,只存資料或狀態,不帶任何的方法。

那方法怎麼辦?

方法會由 Julia 的多重分派(multiple dispatch)引入。

也就是說,我只要去設計任何函式,這個函式他所用到的參數型別,那這個函式就是這些參數的方法。

Julia會視函式呼叫的參數型別組合,來決定到底要執行哪一種方法。

看完之後應該會很傻眼,既然沒有封裝,然後又不帶方法,那這樣要怎麼實現物件導向呢?

Julia 的型別系統

概念上來說,Julia會藉由兩種型別來建立型別階層(type hierarchy):抽象型別跟複合型別

1
2
3
4
5
6
7
abstract type Number end
abstract type Complex <: Number end
abstract type Real <: Number end

struct Integer <: Real
x
end

以上宣告了 3 個抽象型別以及一個複合型別。

抽象型別只有一行宣告,也就是說明這代表什麼樣的概念,他不具有欄位。

<: 代表的是 is subtype ofabstract type Complex <: Number end代表的是ComplexNumber的子型別。

複合型別就像Integer,他有一個field x,他是Real的子型別。

子型別可以是抽象型別或是複合型別,但相反的是,父型別只能是抽象型別。

以上我們描繪了一個型別階層,位於最頂端的是Number,所有人都是Number的子型別。

而複合型別只能位於這棵型別階層的末端的位置。

在Julia中,所有的型別都是Any的子型別,相反,所有型別的子型別則是Union{}

這樣的階層完全不俱備傳統物件導向的繼承概念,雖然觀念上也滿足is-a的關係,但是他並沒有從父型別上繼承任何東西,也沒有東西可以被繼承。

你可能會問:那這樣的型別階層有什麼用呢?

這就要配合多重分派來用拉~~

Julia 的多重分派

大家比較熟悉的應該是single dispatch,也就是區別foo.method()bar.method()的不同。

傳統的物件導向當他呼叫foo.method()bar.method()這兩者時,同樣都是呼叫method()這個函式,可是到底要呼叫誰的實作呢?

Single dispatch會告訴你:去找擁有這個函式的人,也就是.前面的那個傢伙。

但是Julia的設計理念是:函式並不屬於任何人,也就是不會有人擁有函式。

所以Julia裡的函式會是method(foo)method(bar)這樣的形式。

Julia設計上很聰明,他會告訴函式去執行擁有跟參數型別相同排列組合的那個實作。

這也就是多重分派了,他不依賴單一的型別去判斷,而是所有的參數型別的組合!

所以當你宣告了一個像這樣的函式:

1
2
3
function foo(a, b, c)
....
end

他會接受任意的3個參數,當沒宣告參數型別時就自動是Any

那以下這個狀況:

1
2
3
function foo(a::Int64, b)
....
end

就只接受第一個參數型別是Int64,而輸入兩個參數的情況了。

OOP in Julia

以這樣的type system以及多重分派作為建構物件導向的核心機制。

我們不能在以傳統的思維去想物件導向。

我們需要思考的是物件導向的核心是什麼?

物件導向的引入主要幫助我們去塑造一個物件概念,並且幫助我們減少重複的程式碼,提高程式碼的再利用(reuse)。

我們訴求的是高內聚,低耦合的程式碼。

在Julia中,複合型別提供了一個類似物件、結構的角色,由於沒有繼承關係,也不會有過高的內聚。

繼承所需要的元素可以由合成(composition)來達成,但相對犧牲了部份的欄位再利用的可能性。

方法的部份還原成了最初始的函式的樣貌,而函式本身是更加自由的。

藉由多重分派可以更加細緻地定義函式的行為,可以在不同的參數型別上提供多樣的語意。

例如,*(Int64, Int64)的含意是數字的乘法,*(String, String)則是字串的串接,同樣的運算子或是函數名稱,可以提供不同context下,不同的語意,這不是很像人類的語言習慣嗎?

在Julia的精神中,我們關注的是context跟行為,去定義不同context下的函式以及語意(行為)是重要的。

這方面Julia提供了絕佳的多型(polymorphism),但完全不支援繼承與封裝,對資訊的存取權更是無法控制。

但他更強調的是readability!如果設計者考慮到context,那讀程式碼的人就更能進入這個context去思考。

在函式的使用上更是毋庸置疑的靈活而鬆耦合,所以在程式碼的撰寫上,可以自然的達成高內聚,低耦合,更可以提升readability,最後的再利用就端看設計者最佳化的功力拉!

Julia是一個達成物件導向精神的語言!

State-centered and behavior-centered OOP

The state-centered OOP

傳統大家認知的物件導向,大多都由一個概念或是實體所構成。

大家在做程式設計的時候,大家所關注的大多是一個變數的值是如何變化或是一個物件的狀態如何變化

所以很自然的會從一個實體或是概念著手,進而去設計他應該有怎樣的properties或是做什麼事情。

會慢慢凝聚出一個概念,並且把這個概念化成一個個的class,並把這個class描繪清楚。

class描繪仔細了以後,就去關注class跟class之間的互動關係,進而完成整個演算法或是架構。

The behavior-centered OOP

在Julia的物件導向則是敘述關係或是行為

Julia會以函式的角度出發,Julia中的物件導向指的是design a set of methods這回事。

Julia會使你關注在參數之間的關係,他們應該如何一起完成工作?參數如何被dispatch?函式跟函式之間會不會衝突?

型別應該會有什麼樣的行為?*用在數字上跟用在字串上的語意有沒有一樣?

方法其實是在圈定一群型別,他們會一起作用,就是這麼簡單。

所以Julia的物件導向觀點都會圍繞在設計方法上面,而這樣的觀點又可以讓Julia很自然的融入functional programming當中。

這讓Julia可以花比較少力氣在斤斤計較欄位的設計上,也花比較少力氣在思考繼承關係上。

相對花比較多力氣在這個方法被宣告、進入整個語言系統當中的運作情形,也就是對整個系統的影響上。

留言與分享

Yueh-Hua Tu

目標是計算生物學家!
Systems Biology, Computational Biology, Machine Learning
Julia Taiwan 發起人


研發替代役研究助理


Taiwan