Python 中的線性優化,第 2 部分: 在雲中構建一個可擴展的基礎架構

轉自 http://www.ibm.com/developerworks/cn/cloud/library/cl-optimizepythoncloud2/

簡介

這個由三部分組成的系列文章的第 1 部分介紹了在 Python 中使用 Pyomo 庫進行線性優化的基礎知識。現在我們將介紹如何擴展它。Python 缺乏真實的操作系統線程,該如何擴展它?本文將向您介紹如何組合使用這些技術來創建一個實際的可擴展基礎架構,該架構可用於構建一個 Pyomo Web 解決方案。我們組合使用了一個單線程事件循環、一個 AMQP 服務器和工作線程流程來創建一個模式,利用該模式擴展一個線性優化系統。該基礎架構也適用於 Python 或 Ruby 中的許多通用計算問題。

擴展

使用 Python 這類腳本語言創建可擴展、可並行運行的代碼所面臨的挑戰不同於 C, C++ or Java 編程語言。兩個重要因素是執行速度和線程建模。複雜一點來說,Amdahl 定律(參閱 參考資料)顯示,程序的加速與計算機上的可用處理器數量並不是嚴格成正比的。代碼並行部分的比例性最終受限於程序連續部分所需的時間。

考慮該問題的一個直觀方法是將該問題想象成爲一個事件循環,事件循環的每個滴答 (tick) 需要佔用 1 秒的 CPU 時間。在事件循環內部,如果您試着調試就會發現 75% 的時間花在了 time.sleep (.75) 命令上,餘下的時間都花在做這項工作上。因爲這個一秒事件循環的 75% 的時間花費在休眠上,讓部分運行循環可以並行運行是不可能的。在這一秒時間中,惟一可以運行得更快的是這 25% 沒有休眠的部分。在這 .25 秒的事件循環中,無論處理器運行得多快或有多少臺處理器被拋出,總是需要 .75 秒以上的時間來優化 “可並行” 代碼。

即使採用了 Amdahl 定律,原則上,添加更多的線程不會讓 Python 代碼的並行部分癱瘓。儘管這類策略在 C# 和 Java 這類語言中非常適用,但在 Python 中卻略微有些複雜。在 Python 中,如果一部分代碼可以並行化,但這部分代碼承擔了 CPU 的工作,那麼因爲 Global Interpreter Lock (GIL) 的緣故,線程不會提供任何優化方面的幫助。

最後一個比較複雜的地方是,如果編寫了一個 Web 應用程序,該程序使用 Tornado 這類事件驅動基礎架構執行線性優化,那麼必須將一些特殊想法融入到設計中。如果事件循環遭到阻塞而無法執行 CPU 工作或者阻塞網絡操作,那麼該設計可以安全地調用 迭代服務器。W. Richard Stevens 在 Unix Network Programming 一書中介紹了兩種服務器:

  • 迭代服務器:無法處理掛起客戶端,直至當前客戶端服務徹底結束。
  • 併發服務器:通常爲每個請求生成一個子進程。

如果沒有經過慎重考慮,事件循環服務器(Ruby 的 Event Machine 和 Python 的 Tornado)很快就會變成一個迭代服務器。在現實世界中,迭代服務器就是一個玩具,必須不惜一切代價加以避免。

事件循環編程

對於一個事件循環,執行一個線性優化算法的 CPU 密集型工作將鎖定客戶後面發出的所有請求,直至第一個請求的工作進程完成。在許多情況下,這不是最佳做法,因爲可以使用服務器上的其他內核完成其他 CPU 密集型工作。很容易出現的一種境況是:在一個服務器的 24 個內核中,其中有 23 個無所事事,而 CPU 密集型請求卻開始形成一個指數隊列。同樣地,事件循環中如果出現堵塞網絡的操作,那麼可能是出現了更糟的情況——24 個內核都處於閒置狀態,而請求卻開始排隊等候。

使用一個事件循環基礎架構的技巧是確保兩件事:

  • 所有網絡操作必須以非阻塞方式完成,要麼在單獨的線程或進程中,要麼使用 I/O 多路技術(比如選擇和輪訊),要麼使用 POSIX aio_ 函數的異步 I/O。
  • 在事件循環中,應最大程度地減少 CPU 或網絡操作,因爲這會阻塞事件循環。

簡言之,如果使用它作爲一個 Web 站點,就不要出現阻塞事件循環的情況。應該採取其他措施來讓 CPU 完成其他的工作。在 Python 中,一個策略是同步地接收消息,然後將任務負載轉移到訂閱 Advanced Message Queueing Protocol (AMQP) 消息總線(比如,RabbitMQ)的一系列工作線程中。這就是本文將要介紹的策略。異步事件循環服務器的最佳平衡點是處理許多併發、長時間運行的套接字連接。一個真實實例是從一個網絡連接(比如收集股票數據的套接字)將數據發送到另一個連接(比如 WebSockets 連接)。

創建一個 Tornado web 應用程序來分發 Pyomo 應用程序

該示例使用了幾種不同技術:

  • Tornado(參閱 參考資料)是一個開源、可擴展、無阻塞 Web 服務器。它可以充當異步消息傳遞系統。
  • Pyomo(參閱 參考資料)服務在每個請求上運行的線性優化任務。
  • RabbitMQ 可以充當層之間的消息總線。

要搭建環境和運行該實例,需要在 OS X 上安裝以下組件。

  1. 使用 HomeBrew 安裝 RabbitMQ。
  2. Pyomo
  3. Pika,一個 RabbitMQ Python 客戶端。
  4. Tornado,一個異步 Web 服務器。使用 easy_install 安裝。

代碼說明

讓我們開始檢查基礎架構中 Tornado 的部分,通過使用 ApacheBench 向一個基本的、阻塞的 RabbitMQ 服務快速發送消息來進行測試。


清單 1. 對一個基礎消息服務進行基準測試。
				
ab -n 2000 -c 100 http://127.0.0.1:8888/benchmark
This is ApacheBench, Version 2.3 <$Revision: 1178079 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 200 requests
Completed 400 requests
Completed 600 requests
Completed 800 requests
Completed 1000 requests
Completed 1200 requests
Completed 1400 requests
Completed 1600 requests
Completed 1800 requests
Completed 2000 requests
Finished 2000 requests


Server Software:        TornadoServer/2.3
Server Hostname:        127.0.0.1
Server Port:            8888

Document Path:          /benchmark
Document Length:        24 bytes

Concurrency Level:      100
Time taken for tests:   6.231 seconds
Complete requests:      2000
Failed requests:        0
Write errors:           0
Total transferred:      360000 bytes
HTML transferred:       48000 bytes
Requests per second:    320.96 [#/sec] (mean)
Time per request:       311.570 [ms] (mean)
Time per request:       3.116 [ms] (mean, across all concurrent requests)
Transfer rate:          56.42 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.5      0       4
Processing:    11  304  42.9    308     605
Waiting:       11  304  42.9    308     605
Total:         15  304  42.7    309     608

Percentage of the requests served within a certain time (ms)
  50%    309
  66%    311
  75%    312
  80%    313
  90%    315
  95%    324
  98%    336
  99%    338
 100%    608 (longest request)

這裏有一個特殊的 Web 請求處理程序,稱爲 /benchmark。在該處理程序的內部,發送給 RabbitMQ 的簡單消息是通過 pika.BlockingConnection 方法發出的。查看每秒從 ApacheBench 輸出的請求就會發現,平均每秒大約發出 320 個請求。這不是很可怕,同時數量也不算多。Tornado 官方基準測試每秒鐘大約發出 3353 個請求。如果實際應用程序中有一個真正的瓶頸,那麼很容易前進到下一步,將阻塞的 RabbitMQ 消息發送轉換成一個完全的異步方法。pika 庫有一個稱爲 SelectConnection 的異步適配器,可以將基準轉換成接近每秒 3353 個請求。

RabbitMQ 本身每秒可接受數以萬計的消息。發佈的基準測試顯示每秒可並行生成和消耗 64315 條消息。這可能需要採用某個數量級以上的 Tornado 工作線程來重載一個 RabbitMQ 實例,甚至在異步模式下也可以完成此操作。假設要進行一個基本的 RabbitMQ 安裝,而且聲明瞭一個交換(exchange ),即 pyomo-job-exchange 交換。Tornado Web 服務器使用 routing_key pyomo-job-exchange 進行發佈。除此之外,還有許多工作線程進程將使用 pyomo-job-exchange。

在這個 pika 工作線程層中,有許多工作線程訂閱了 pyomo-job-exchange 消息。每個工作線程都是 “啞的 (dumb)”,這意味着它們會機械地接受參數數據,通過 Pyomo 使用這些數據來執行線性優化。如果您喜歡的話,可以將 pika 工作線程的最終結果直接通過 STOMP 插件(參閱 參考資料,獲取介紹具體操作的文章鏈接)發送給訂閱 RabbitMQ 的 WebSockets。要實現的惟一附加部分是告訴 pika 工作線程進程如何將最終結果返回給訂閱了 WebSocket 工作線程的 RabbitMQ 隊列。


圖 1. Tornado-Pyomo-RabbitMQ 基礎架構
Tornado-Pyomo-RabbitMQ 基礎架構

清單 2 展示了 Tornado Web 服務器代碼,要啓動該服務器,請執行命令 python server.py


清單 2. Tornado 服務器
				
import tornado.ioloop
import tornado.web
import tornado.websocket
import pika
import time

def publish_to_rabbitmq(msg):
    "blocking message sent to RabbitMQ"
    
    credentials = pika.PlainCredentials("guest", "guest")
    conn_params = pika.ConnectionParameters("localhost",
                                            credentials = credentials)
    conn_broker = pika.BlockingConnection(conn_params) 
    channel = conn_broker.channel() 
    channel.exchange_declare(exchange="pyomo-job-exchange", 
                             type="direct",
                             passive=False,
                             durable=False,
                             auto_delete=False)
    
    msg_props = pika.BasicProperties()
    msg_props.content_type = "text/plain"
    channel.basic_publish(body=msg,
                          exchange="pyomo-job-exchange",
                          properties=msg_props,
                          routing_key="pyomo-job-dispatch")

def consume_from_rabbitmq():
    pass

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        msg = "%s" % time.asctime()
        publish_to_rabbitmq(msg)
        self.write(msg)

class PyomoTask(tornado.web.RequestHandler):
    def get(self):
        self.write('<html><body><form action="/pyomo" method="post">'
                   '<input type="text" name="ProfitRateWindows">'
                   '<input type="submit" value="ProfitRateWindows">'
                   '</form></body></html>')

    def post(self):
        self.set_header("Content-Type", "text/plain")
        result = self.get_argument("ProfitRateWindows")
        publish_to_rabbitmq(result)
        self.write("Submitted to RabbitMQ/Pyomo Worker: " + result)
        
class PyomoWebSocketResult(tornado.websocket.WebSocketHandler):

    def open(self):
        """Called when a websocket opens"""
    pass        

application = tornado.web.Application([
    (r"/benchmark", MainHandler),
    (r"/pyomo", PyomoTask),
    (r"/websocket", PyomoWebSocketResult),
    
])

if __name__ == "__main__":
    application.listen(8888)
    tornado.ioloop.IOLoop.instance().start()

Tornado Web 服務器有三個主要處理程序:/benchmark、/pyomo 和 /websockets(我把它留給您來實現)。/pyomo 處理程序是一種樸實的實現形式,它接收一個值,然後將其發送給 RabbitMQ,該值是從上面的示例中提取出來的:

result = self.get_argument("ProfitRateWindows")
publish_to_rabbitmq(result)

現在我們來看看 Pyomo 工作線程代碼,如 Listing 3 所示。要執行該工作線程,請運行以下命令:

source coopr/bin/activate
python worker.py

訪問 http://localhost:8888/pyomo 併爲 Profit Rate Windows 提交一個數值。


清單 3. Pyomo 工作線程
				
import pika
from coopr.pyomo import (ConcreteModel, Objective, Var, NonNegativeReals,
                              maximize, Constraint)
from coopr.opt import SolverFactory
import time

def do_pyomo_work(profit_rate_windows):
    
    Products = ['Doors', 'Windows']
    ProfitRate = {'Doors':300, 'Windows':profit_rate_windows}
    Plants = ['Door Fab', 'Window Fab', 'Assembly']
    HoursAvailable = {'Door Fab':4, 'Window Fab':12, 'Assembly':18}
    HoursPerUnit = {('Doors','Door Fab'):1, ('Windows', 'Window Fab'):2,
                    ('Doors','Assembly'):3, ('Windows', 'Assembly'):2,
                    ('Windows', 'Door Fab'):0, ('Doors', 'Window Fab'):0}
    
    #Concrete Model
    model = ConcreteModel()
    #Decision Variables
    model.WeeklyProd = Var(Products, within=NonNegativeReals)
    
    #Objective
    model.obj = Objective(expr=
                sum(ProfitRate[i] * model.WeeklyProd[i] for i in Products),
                sense = maximize)
    
    def CapacityRule(model, p):
        """User Defined Capacity Rule
        
        Accepts a pyomo Concrete Model as the first positional argument,
        and a list of Plants as a second positional argument
        """
        
        return sum(HoursPerUnit[i,p] * model.WeeklyProd[i] for i in Products) 
                                  <= HoursAvailable[p]

    model.Capacity = Constraint(Plants, rule = CapacityRule)
    opt = SolverFactory("glpk")
    instance = model.create()
    results = opt.solve(instance)
    #results.write()
    return results.Solution()

def create_channel():
    credentials = pika.PlainCredentials("guest", "guest")
    conn_params = pika.ConnectionParameters("localhost",
                                        credentials = credentials)
    conn_broker = pika.BlockingConnection(conn_params) 
    channel = conn_broker.channel() 
    channel.exchange_declare(exchange="pyomo-job-exchange", 
                         type="direct",
                         passive=False,
                         durable=False,
                         auto_delete=False)
    channel.queue_declare(queue="pyomo-queue") 
    channel.queue_bind(queue="pyomo-queue",     
                   exchange="pyomo-job-exchange",
                   routing_key="pyomo-job-dispatch")
    return channel

def consume_run_loop():
    channel = create_channel()
    def msg_consumer(channel, method, header, body):
        channel.basic_ack(delivery_tag=method.delivery_tag) 
        print body
        now = time.time()
        res = do_pyomo_work(int(body))
        print res
        print "Pyomo Job Completed in: %s seconds" % round(time.time() - now, 2)
        return
    channel.basic_consume( msg_consumer, 
                           queue="pyomo-queue",
                           consumer_tag="pyomo-consumer")
    channel.start_consuming()

consume_run_loop()

該工作線程由兩個操作組成。首先是修改現有 Wyndor 示例,讓 Web 表格逐漸代替 ProfitRate ={'Doors':300, 'Windows':profit_rate_windows} 值。在實際應用程序中,此操作是完全主觀且不切實際的,但它讓本文的演示變得更容易。在實際應用程序中,線性優化的許多內容很有可能是動態分配的。

接下來,工作線程實際上一直處於 “阻塞” 狀態,等待來自 RabbitMQ 的請求。然後獲取各個結果並將其發送給回調函數 msg_consumer,該回調函數會運行線性優化,並將結果以及運行代碼所用的時間打印到 stfout。注意,可能會出現 “N” 個工作線程進程,第一個工作線程捕獲第一個作業。這需要考慮使用一個相對較爲容易的擴展模型,因爲每個工作線程 “啞” 的程度不同。此外,沒有共享的狀態可供參考,只有一個消息需要處理。

在觀察兩種不同的 Web 表單提交方法的過程中(該表單反過來由 Pyomo 工作線程處理),可以看到每個表單都會接收請求,CPU 大約耗費了一秒的 2/100。


清單 4. 工作線程輸出
				
python worker.py
100

Gap: 0.0
Status: feasible
Objective: 
  obj: 
    Id: 0
    Value: 1500.0
Variable: 
  WeeklyProd(Doors):
    Id: 0
    Value: 4
  WeeklyProd(Windows):
    Id: 1
    Value: 3
Constraint: 
  c_u_Capacity(Assembly)_: 
    Id: 0
    Value: 18.0
  c_u_Capacity(Door_Fab)_: 
    Id: 1
    Value: 4.0
  c_u_Capacity(Window_Fab)_: 
    Id: 2
    Value: 6.0

Pyomo Job Completed in: 0.02 seconds
500

Gap: 0.0
Status: feasible
Objective: 
  obj: 
    Id: 0
    Value: 3600.0
Variable: 
  WeeklyProd(Doors):
    Id: 0
    Value: 2
  WeeklyProd(Windows):
    Id: 1
    Value: 6
Constraint: 
  c_u_Capacity(Assembly)_: 
    Id: 0
    Value: 18.0
  c_u_Capacity(Door_Fab)_: 
    Id: 1
    Value: 2.0
  c_u_Capacity(Window_Fab)_: 
    Id: 2
    Value: 12.0

Pyomo Job Completed in: 0.02 seconds

結束語

該基礎架構還有一個額外優勢:RabbitMQ 是語言無關的,因此您可以用另一種語言中的新組件替換任何組件。此外,RabbitMQ 是用 Erlang 語言構建的,可能會產生數以百萬計的輕量級進程,每個進程耗時幾微秒(百萬分之一秒);因此,RabbitMQ 可以用作非常可靠的消息總線,有效地進行水平擴展。合理擴展 Ruby 和 Python 的一個方法是將它們與 RabbitMQ 這類技術相結合,從而可以在承受巨大負載的情況下進行水平擴展,然後您可以使用 Python 和 Ruby 實現其最佳實踐:快速迭代和原型化。

最後需要注意的是:使用網絡程序設計語言編寫小程序和基準測試程序非常重要。觀察 API 並牢記代碼示例。但是,在進行網絡編程時,通常無法做到這一點。在進行網絡編程時,真正理解工作原理的惟一方法就是親自編寫一個小程序,對其進行基準測試,然後在這個小程序上構建一些內容並進行基準測試,依次類推。如果跳過了這些步驟,您可能會發現您構建的內容是無法擴展的,而您的直覺告訴您它們應該是可以擴展的。

請留意本系列的最後一篇文章,我將在其中介紹使用 IPython 和 Pandas 進行投資分析和統計分析的操作示例。


下載

描述 名字 大小 下載方法
server.py 和 worker.py code-py-files.zip 1.99KB HTTP

關於下載方法的信息


參考資料

學習

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章